Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion console/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion console/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

[package]
name = "console"
version = "1.1.0"
version = "1.2.0"
edition = "2021"
publish = false

Expand Down
22 changes: 13 additions & 9 deletions console/web/src/components/chat/DirectoryPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,11 @@ import { cn } from '@/lib/utils'
* or "browse to add" a new directory. Browsing uses shell's operator workspace
* control plane one level at a time. The search box filters the current level
* live; typing/pasting an absolute path jumps straight there (browse) or
* selects it (projects). A pasted/remembered dir is validated against the live
* shell worker before it's accepted. The chosen dir is what the harness scopes
* the chat to (`base_dir`); it is re-scopable mid-conversation (a change drops
* a visible transcript marker).
* selects it (projects). Every selection — pasted, remembered, or browsed — is
* validated against the live shell worker before it's accepted, and the
* worker-echoed canonical path is what gets stored. The chosen dir is what the
* harness scopes the chat to (`base_dir`); it is re-scopable mid-conversation
* (a change drops a visible transcript marker).
*/

interface DirectoryPickerProps {
Expand Down Expand Up @@ -270,9 +271,11 @@ export function DirectoryPicker({
[onChange],
)

// Validate a pasted/remembered dir against the LIVE worker roots before
// accepting it — a remembered project may be deleted, on another machine, or
// outside the configured roots. Browsed dirs are already known-valid.
// Validate a dir against the LIVE worker before accepting it — a
// remembered project may be deleted, on another machine, or denylisted, and
// even a just-browsed dir can vanish between listing and clicking. Every
// selection path goes through here so the worker-echoed canonical path is
// what gets stored.
const validateAndSelect = useCallback(
async (raw: string) => {
const dir = raw.trim().replace(/\/+$/, '') || '/'
Expand Down Expand Up @@ -503,8 +506,9 @@ export function DirectoryPicker({
{path ? (
<button
type="button"
onClick={() => select(path)}
className="inline-flex items-center gap-1 whitespace-nowrap text-[11px] lowercase text-accent hover:underline"
disabled={validating !== null}
onClick={() => void validateAndSelect(path)}
className="inline-flex items-center gap-1 whitespace-nowrap text-[11px] lowercase text-accent hover:underline disabled:opacity-50"
>
<Check size={12} aria-hidden /> use this folder
</button>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { harnessFixtures } from '@/stories/fixtures/harness-fixtures'
import { routerFixtures } from '@/stories/fixtures/router-fixtures'
import { sandboxFixtures } from '@/stories/fixtures/sandbox-fixtures'
import { shellFixtures } from '@/stories/fixtures/shell-fixtures'
import { stateFixtures } from '@/stories/fixtures/state-fixtures'
import { webFixtures } from '@/stories/fixtures/web-fixtures'
import { workerFixtures } from '@/stories/fixtures/worker-fixtures'
import { workflowFixtures } from '@/stories/fixtures/workflow-fixtures'
Expand Down Expand Up @@ -173,3 +174,8 @@ export const HarnessFamily: Story = {
name: 'harness family',
render: () => <FamilyGallery fixtures={harnessFixtures} />,
}

export const StateFamily: Story = {
name: 'state family',
render: () => <FamilyGallery fixtures={stateFixtures} />,
}
12 changes: 9 additions & 3 deletions console/web/src/components/chat/FunctionCallMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
SandboxToolView,
} from '@/components/chat/sandbox'
import { ShellFunctionIdLabel, ShellToolView } from '@/components/chat/shell'
import { StateFunctionIdLabel, StateToolView } from '@/components/chat/state'
import { WebFunctionIdLabel, WebToolView } from '@/components/chat/web'
import { WorkerFunctionIdLabel, WorkerToolView } from '@/components/chat/worker'
import {
Expand Down Expand Up @@ -140,6 +141,9 @@ function FunctionIdLabel({ functionId }: { functionId: string }) {
if (HarnessToolView.isHarnessFunction(functionId)) {
return <HarnessFunctionIdLabel functionId={functionId} />
}
if (StateToolView.isStateFunction(functionId)) {
return <StateFunctionIdLabel functionId={functionId} />
}
return <span className="text-ink">{functionId}</span>
}

Expand Down Expand Up @@ -170,7 +174,8 @@ export function FunctionCallMessage({
ShellToolView.tryRenderPreview(message) ??
WorkflowToolView.tryRenderPreview(message) ??
RouterToolView.tryRenderPreview(message) ??
HarnessToolView.tryRenderPreview(message)
HarnessToolView.tryRenderPreview(message) ??
StateToolView.tryRenderPreview(message)
const customTerminal = !pending
? (SandboxToolView.tryRender(message) ??
EngineToolView.tryRender(message) ??
Expand All @@ -181,13 +186,14 @@ export function FunctionCallMessage({
ShellToolView.tryRender(message) ??
WorkflowToolView.tryRender(message) ??
RouterToolView.tryRender(message) ??
HarnessToolView.tryRender(message))
HarnessToolView.tryRender(message) ??
StateToolView.tryRender(message))
: null
const hasCustomTerminal = customTerminal != null
const showRequestPaneAbove =
!(pending && customPreview) &&
!(running && hasCustomTerminal) &&
!(!pending && !running && hasCustomTerminal)
!(!pending && !running)

const runResolve = async (kind: 'approve' | 'deny' | 'always_allow') => {
const handler =
Expand Down
208 changes: 208 additions & 0 deletions console/web/src/components/chat/engine/RegisterTriggerView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
import type { ReactNode } from 'react'
import { Chip, MetaRow, StatusPill } from '@/components/chat/sandbox/shared'
import { JsonHighlight } from '@/lib/syntax'
import {
type ReactOptions,
type ReactSpec,
type RegisterTriggerRequest,
type RegisterTriggerResponse,
reactOptionsSchema,
reactSpecSchema,
registerTriggerRequestSchema,
registerTriggerResponseSchema,
type StateTriggerConfig,
safeParseRequest,
safeParseResponse,
stateTriggerConfigSchema,
} from './parsers'
import { FilterChip } from './shared'

interface RegisterTriggerViewProps {
input: unknown
output: unknown
running?: boolean
}

export function RegisterTriggerView({
input,
output,
running,
}: RegisterTriggerViewProps) {
const req = safeParseRequest<RegisterTriggerRequest>(
registerTriggerRequestSchema,
input,
)
// Never render blank: an unrecognized payload falls back to raw JSON rather
// than an empty terminal pane (the switch always mounts this component).
if (!req) return <LabeledJson label="request" value={input} />

const stateCfg =
req.trigger_type === 'state'
? safeParseRequest<StateTriggerConfig>(
stateTriggerConfigSchema,
req.config,
)
: null
const react =
req.function_id === 'harness::react'
? safeParseRequest<ReactSpec>(reactSpecSchema, req.metadata)
: null
const allow = react
? safeParseRequest<ReactOptions>(reactOptionsSchema, react.options)
?.functions?.allow
: undefined

const resp = running
? null
: safeParseResponse<RegisterTriggerResponse>(
registerTriggerResponseSchema,
output,
)
const regId = resp?.id ?? resp?.subscription_id
const once = resp?.once ?? req.once

const hasStateChips =
!!stateCfg &&
(!!stateCfg.scope || !!stateCfg.key || !!stateCfg.condition_function_id)

return (
<div className="border-t border-rule-2 bg-bg">
<MetaRow>
<StatusPill
label={running ? 'registering trigger…' : 'trigger registered'}
variant={running ? 'default' : 'accent'}
/>
{req.label ? <FilterChip label="label" value={req.label} /> : null}
{typeof once === 'boolean' ? (
<FilterChip label="mode" value={once ? 'one-shot' : 'persistent'} />
) : null}
{regId ? (
<Chip>
<span className="text-ink-faint uppercase tracking-[0.06em]">
id
</span>
<span className="ml-1 text-ink" title={regId}>
{shortenId(regId)}
</span>
</Chip>
) : null}
</MetaRow>

<div className="px-3 py-2 border-b border-rule-2 bg-bg flex items-baseline gap-2 flex-wrap">
<span className="font-mono text-[11px] uppercase tracking-[0.06em] text-ink-faint">
{req.trigger_type}
</span>
<span className="font-mono text-[11px] text-ink-faint">→</span>
{req.function_id ? (
<span className="font-mono text-[12.5px] text-accent break-all">
{req.function_id}
</span>
) : (
<span className="font-mono text-[12.5px] text-ink-faint italic">
notify session
</span>
)}
</div>

{hasStateChips ? (
<div className="px-3 py-1.5 border-b border-rule-2 bg-paper-2 flex flex-wrap items-center gap-1.5">
{stateCfg?.scope ? (
<FilterChip label="scope" value={stateCfg.scope} />
) : null}
{stateCfg?.key ? (
<FilterChip label="key" value={stateCfg.key} />
) : null}
{stateCfg?.condition_function_id ? (
<FilterChip label="if" value={stateCfg.condition_function_id} />
) : null}
</div>
) : req.config !== undefined && !isEmpty(req.config) ? (
<LabeledJson label="config" value={req.config} />
) : null}

{react ? (
<>
<div className="px-3 py-1.5 border-b border-rule-2 bg-paper-2 flex flex-wrap items-center gap-1.5">
<FilterChip label="model" value={react.model} />
{allow?.length
? Array.from(new Set(allow)).map((fn) => (
<Chip key={fn}>
<span className="text-ink">{fn}</span>
</Chip>
))
: null}
</div>
{react.join ? (
<div className="px-3 py-2 border-b border-rule-2 bg-bg font-mono text-[12px] text-ink flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="text-ink-faint uppercase tracking-[0.06em] text-[10px]">
join
</span>
<span className="text-accent break-all">{react.join.id}</span>
<span className="text-ink-ghost">·</span>
<span>
key <span className="text-ink-faint">{react.join.key}</span>
</span>
<span className="text-ink-ghost">·</span>
<span>
expect{' '}
<span className="text-ink-faint">
[{react.join.expect.join(', ')}]
</span>
</span>
{react.join.rearm ? (
<>
<span className="text-ink-ghost">·</span>
<span className="text-accent">rearm</span>
</>
) : null}
</div>
) : null}
<LabeledText label="task" text={react.task} />
</>
) : req.metadata !== undefined ? (
<LabeledJson label="metadata" value={req.metadata} />
) : null}
</div>
)
}

function isEmpty(v: unknown): boolean {
if (v === null || v === undefined) return true
if (typeof v === 'object') {
return Object.keys(v as Record<string, unknown>).length === 0
}
return false
}

function shortenId(id: string): string {
if (id.length <= 14) return id
return `${id.slice(0, 8)}…${id.slice(-4)}`
}

function PaneLabel({ children }: { children: ReactNode }) {
return (
<div className="bg-paper-2 px-3 py-1.5 border-b border-rule-2 font-mono text-[11px] uppercase tracking-[0.06em] text-ink-faint">
{children}
</div>
)
}

function LabeledJson({ label, value }: { label: string; value: unknown }) {
return (
<div>
<PaneLabel>{label}</PaneLabel>
<JsonHighlight code={JSON.stringify(value ?? null, null, 2)} />
</div>
)
}

function LabeledText({ label, text }: { label: string; text: string }) {
return (
<div>
<PaneLabel>{label}</PaneLabel>
<pre className="bg-bg overflow-x-auto px-3 py-2 font-mono text-[12.5px] leading-[1.55] text-ink whitespace-pre-wrap break-words">
<code>{text}</code>
</pre>
</div>
)
}
Loading