diff --git a/.changeset/add-projects-working-directory.md b/.changeset/add-projects-working-directory.md new file mode 100644 index 0000000000..ca947e400d --- /dev/null +++ b/.changeset/add-projects-working-directory.md @@ -0,0 +1,7 @@ +--- +'@electric-ax/agents-server': patch +'@electric-ax/agents-server-ui': patch +'@electric-ax/agents': patch +--- + +Add server-side projects (name + directory path) with REST API, popover-based project picker in the new session page, and working directory support in horton. Sessions are tagged by project for sidebar grouping. diff --git a/.changeset/agents-runtime-tool-call-fix.md b/.changeset/agents-runtime-tool-call-fix.md new file mode 100644 index 0000000000..af96006a43 --- /dev/null +++ b/.changeset/agents-runtime-tool-call-fix.md @@ -0,0 +1,5 @@ +--- +'@electric-ax/agents-runtime': patch +--- + +Fix tool call event matching. diff --git a/packages/agents-runtime/src/context-assembly.ts b/packages/agents-runtime/src/context-assembly.ts index b89b50077e..9f63316a63 100644 --- a/packages/agents-runtime/src/context-assembly.ts +++ b/packages/agents-runtime/src/context-assembly.ts @@ -324,12 +324,43 @@ export async function assembleContext( const message = volatileMessages[i]! const nextTokens = approxTokens(message.content) if (volatileBudgetUsed + nextTokens > remainingBudget) { + if (message.role === `tool_call` || message.role === `tool_result`) { + const stub = `[content truncated — use load_timeline_range({ from: ${message.at}, to: ${message.at} }) to read]` + const stubTokens = approxTokens(stub) + if (volatileBudgetUsed + stubTokens <= remainingBudget) { + volatileBudgetUsed += stubTokens + accepted.push({ ...message, content: stub }) + continue + } + } droppedOffsets.push(message.at) continue } volatileBudgetUsed += nextTokens accepted.push(message) } + + const acceptedCallIds = new Set() + const acceptedResultIds = new Set() + for (const m of accepted) { + const id = (m as VolatileMessage & { toolCallId?: string }).toolCallId + if (!id) continue + if (m.role === `tool_call`) acceptedCallIds.add(id) + else if (m.role === `tool_result`) acceptedResultIds.add(id) + } + for (let i = accepted.length - 1; i >= 0; i--) { + const m = accepted[i]! + const id = (m as VolatileMessage & { toolCallId?: string }).toolCallId + if (!id) continue + if ( + (m.role === `tool_call` && !acceptedResultIds.has(id)) || + (m.role === `tool_result` && !acceptedCallIds.has(id)) + ) { + droppedOffsets.push(m.at) + accepted.splice(i, 1) + } + } + accepted.reverse() if (droppedOffsets.length > 0) { diff --git a/packages/agents-runtime/src/entity-schema.ts b/packages/agents-runtime/src/entity-schema.ts index 663e53efcb..8df08882c5 100644 --- a/packages/agents-runtime/src/entity-schema.ts +++ b/packages/agents-runtime/src/entity-schema.ts @@ -109,6 +109,7 @@ type TextDeltaValue = { type ToolCallValue = { key?: string run_id?: string + tool_call_id?: string tool_name: string status: `started` | `args_complete` | `executing` | `completed` | `failed` args?: unknown @@ -353,6 +354,7 @@ function createToolCallSchema(): Schema { return z.object({ key: z.string().optional(), run_id: z.string().optional(), + tool_call_id: z.string().optional(), tool_name: z.string(), status: z.enum([ `started`, diff --git a/packages/agents-runtime/src/outbound-bridge.ts b/packages/agents-runtime/src/outbound-bridge.ts index e9e4c688e1..0b87c6c7ba 100644 --- a/packages/agents-runtime/src/outbound-bridge.ts +++ b/packages/agents-runtime/src/outbound-bridge.ts @@ -110,8 +110,13 @@ export interface OutboundBridge { onTextStart: () => void onTextDelta: (delta: string) => void onTextEnd: () => void - onToolCallStart: (name: string, args: unknown) => void - onToolCallEnd: (name: string, result: unknown, isError: boolean) => void + onToolCallStart: (toolCallId: string, name: string, args: unknown) => void + onToolCallEnd: ( + toolCallId: string, + name: string, + result: unknown, + isError: boolean + ) => void } export function createOutboundBridge( @@ -145,9 +150,10 @@ export function createOutboundBridge( let currentStepNumber = 0 let currentMsgKey: string | null = null let currentTextRunKey: string | null = null - let currentTcKey: string | null = null - let currentTcRunKey: string | null = null - let currentTcArgs: unknown = undefined + const toolCallsById = new Map< + string, + { key: string; runKey: string; args: unknown } + >() const requireActiveRun = (action: string): string => { if (!currentRunKey) { throw new Error( @@ -268,16 +274,16 @@ export function createOutboundBridge( ) }, - onToolCallStart(name: string, args: unknown) { + onToolCallStart(toolCallId: string, name: string, args: unknown) { const runKey = requireActiveRun(`onToolCallStart`) - currentTcKey = `tc-${counters.tc++}` + const key = `tc-${counters.tc++}` persistSeed() - currentTcRunKey = runKey - currentTcArgs = args + toolCallsById.set(toolCallId, { key, runKey, args }) writeEvent( entityStateSchema.toolCalls.insert({ - key: currentTcKey, + key, value: { + tool_call_id: toolCallId, tool_name: name, status: `started`, args, @@ -287,21 +293,29 @@ export function createOutboundBridge( ) }, - onToolCallEnd(name: string, result: unknown, isError: boolean) { - if (!currentTcKey) return + onToolCallEnd( + toolCallId: string, + name: string, + result: unknown, + isError: boolean + ) { + const toolCall = toolCallsById.get(toolCallId) + if (!toolCall) return writeEvent( entityStateSchema.toolCalls.update({ - key: currentTcKey, + key: toolCall.key, value: { + tool_call_id: toolCallId, tool_name: name, status: isError ? `failed` : `completed`, - args: currentTcArgs, + args: toolCall.args, result: typeof result === `string` ? result : JSON.stringify(result), - run_id: currentTcRunKey, + run_id: toolCall.runKey, } as never, }) as ChangeEvent ) + toolCallsById.delete(toolCallId) }, } } diff --git a/packages/agents-runtime/src/pi-adapter.ts b/packages/agents-runtime/src/pi-adapter.ts index 04337fa0cb..cec2d2132b 100644 --- a/packages/agents-runtime/src/pi-adapter.ts +++ b/packages/agents-runtime/src/pi-adapter.ts @@ -89,6 +89,11 @@ export function toAgentHistory( const history: Array = [] const toolNamesById = new Map() + const lastAssistant = (): AgentMessage | undefined => { + const last = history[history.length - 1] + return last?.role === `assistant` ? last : undefined + } + for (const message of messages) { switch (message.role) { case `user`: @@ -99,30 +104,44 @@ export function toAgentHistory( } as AgentMessage) break - case `assistant`: - history.push({ - role: `assistant`, - content: [{ type: `text`, text: message.content }], - timestamp: Date.now(), - } as AgentMessage) + case `assistant`: { + const prev = lastAssistant() + if (prev) { + ;(prev.content as Array).push({ + type: `text`, + text: message.content, + }) + } else { + history.push({ + role: `assistant`, + content: [{ type: `text`, text: message.content }], + timestamp: Date.now(), + } as AgentMessage) + } break + } - case `tool_call`: + case `tool_call`: { toolNamesById.set(message.toolCallId, message.toolName) - history.push({ - role: `assistant`, - content: [ - { - type: `toolCall`, - id: message.toolCallId, - name: message.toolName, - arguments: - (message.toolArgs as Record | undefined) ?? {}, - }, - ], - timestamp: Date.now(), - } as AgentMessage) + const block = { + type: `toolCall`, + id: message.toolCallId, + name: message.toolName, + arguments: + (message.toolArgs as Record | undefined) ?? {}, + } + const prev = lastAssistant() + if (prev) { + ;(prev.content as Array).push(block) + } else { + history.push({ + role: `assistant`, + content: [block], + timestamp: Date.now(), + } as AgentMessage) + } break + } case `tool_result`: history.push({ @@ -297,12 +316,17 @@ export function createPiAgentAdapter( } case `tool_execution_start`: { - bridge.onToolCallStart(event.toolName, event.args) + bridge.onToolCallStart( + event.toolCallId, + event.toolName, + event.args + ) break } case `tool_execution_end`: { bridge.onToolCallEnd( + event.toolCallId, event.toolName, event.result, event.isError diff --git a/packages/agents-runtime/src/types.ts b/packages/agents-runtime/src/types.ts index 4a04f4e8d5..e8b57d4f2b 100644 --- a/packages/agents-runtime/src/types.ts +++ b/packages/agents-runtime/src/types.ts @@ -753,8 +753,13 @@ export interface OutboundBridgeHandle { onTextStart: () => void onTextDelta: (delta: string) => void onTextEnd: () => void - onToolCallStart: (name: string, args: unknown) => void - onToolCallEnd: (name: string, result: unknown, isError: boolean) => void + onToolCallStart: (toolCallId: string, name: string, args: unknown) => void + onToolCallEnd: ( + toolCallId: string, + name: string, + result: unknown, + isError: boolean + ) => void } export interface AgentHandle { diff --git a/packages/agents-runtime/test/brave-search-tool.test.ts b/packages/agents-runtime/test/brave-search-tool.test.ts new file mode 100644 index 0000000000..89ef86992f --- /dev/null +++ b/packages/agents-runtime/test/brave-search-tool.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from 'vitest' +import { braveSearchTool } from '../src/tools' + +describe(`braveSearchTool`, () => { + it(`is exposed to agents as web_search`, () => { + expect(braveSearchTool.name).toBe(`web_search`) + }) +}) diff --git a/packages/agents-runtime/test/outbound-bridge.test.ts b/packages/agents-runtime/test/outbound-bridge.test.ts index 03b49fe142..0624c1680d 100644 --- a/packages/agents-runtime/test/outbound-bridge.test.ts +++ b/packages/agents-runtime/test/outbound-bridge.test.ts @@ -69,7 +69,9 @@ describe(`createOutboundBridge`, () => { it(`rejects tool call outside an active run`, () => { const bridge = createOutboundBridge([], () => {}) - expect(() => bridge.onToolCallStart(`search`, {})).toThrow(/active run/i) + expect(() => bridge.onToolCallStart(`call-search`, `search`, {})).toThrow( + /active run/i + ) }) it(`rejects step start outside an active run`, () => { @@ -84,7 +86,7 @@ describe(`createOutboundBridge`, () => { }) bridge.onRunStart() - bridge.onToolCallStart(`search`, { q: `test` }) + bridge.onToolCallStart(`call-search`, `search`, { q: `test` }) expect(writes).toHaveLength(2) expect(writes[1]!.type).toBe(`tool_call`) @@ -103,8 +105,8 @@ describe(`createOutboundBridge`, () => { }) bridge.onRunStart() - bridge.onToolCallStart(`search`, { q: `test` }) - bridge.onToolCallEnd(`search`, `3 results`, false) + bridge.onToolCallStart(`call-search`, `search`, { q: `test` }) + bridge.onToolCallEnd(`call-search`, `search`, `3 results`, false) expect(writes).toHaveLength(3) expect(writes[2]!.type).toBe(`tool_call`) @@ -126,13 +128,37 @@ describe(`createOutboundBridge`, () => { }) bridge.onRunStart() - bridge.onToolCallStart(`bash`, { cmd: `rm -rf /` }) - bridge.onToolCallEnd(`bash`, `Permission denied`, true) + bridge.onToolCallStart(`call-bash`, `bash`, { cmd: `rm -rf /` }) + bridge.onToolCallEnd(`call-bash`, `bash`, `Permission denied`, true) expect((writes[2]!.value as Record).status).toBe(`failed`) expect((writes[2]!.value as Record).run_id).toBe(`run-0`) }) + it(`updates the matching tool call when multiple explicit calls overlap`, () => { + const writes: Array = [] + const bridge = createOutboundBridge([], (e) => { + writes.push(e) + }) + + bridge.onRunStart() + bridge.onToolCallStart(`call-a`, `read`, { path: `a.txt` }) + bridge.onToolCallStart(`call-b`, `read`, { path: `b.txt` }) + bridge.onToolCallEnd(`call-a`, `read`, `A`, false) + bridge.onToolCallEnd(`call-b`, `read`, `B`, false) + + expect(writes[3]!.key).toBe(`tc-0`) + expect((writes[3]!.value as Record).tool_call_id).toBe( + `call-a` + ) + expect((writes[3]!.value as Record).result).toBe(`A`) + expect(writes[4]!.key).toBe(`tc-1`) + expect((writes[4]!.value as Record).tool_call_id).toBe( + `call-b` + ) + expect((writes[4]!.value as Record).result).toBe(`B`) + }) + it(`reconstructs ID counters from existing stream events`, () => { const existing: Array = [ ev(`run`, `run-2`, `insert`, { status: `started` }), @@ -152,7 +178,7 @@ describe(`createOutboundBridge`, () => { expect(writes[0]!.key).toBe(`run-3`) expect(writes[1]!.key).toBe(`msg-4`) - bridge.onToolCallStart(`test`, {}) + bridge.onToolCallStart(`call-test`, `test`, {}) expect(writes[2]!.key).toBe(`tc-6`) expect((writes[2]!.value as Record).run_id).toBe(`run-3`) }) @@ -169,7 +195,7 @@ describe(`createOutboundBridge`, () => { bridge.onRunStart() bridge.onStepStart() bridge.onTextStart() - bridge.onToolCallStart(`search`, {}) + bridge.onToolCallStart(`call-search`, `search`, {}) expect(writes[0]!.key).toBe(`run-2`) expect(writes[1]!.key).toBe(`step-4`) @@ -228,7 +254,9 @@ describe(`createOutboundBridge`, () => { bridge.onRunStart() bridge.onRunEnd() - expect(() => bridge.onToolCallStart(`search`, {})).toThrow(/active run/i) + expect(() => bridge.onToolCallStart(`call-search`, `search`, {})).toThrow( + /active run/i + ) }) it(`rejects step start after run ends`, () => { diff --git a/packages/agents-runtime/test/pi-adapter.test.ts b/packages/agents-runtime/test/pi-adapter.test.ts index 0cf9539b27..18190b8b3e 100644 --- a/packages/agents-runtime/test/pi-adapter.test.ts +++ b/packages/agents-runtime/test/pi-adapter.test.ts @@ -230,4 +230,144 @@ describe(`toAgentHistory`, () => { expect(first?.role).toBe(`user`) expect(second?.role).toBe(`assistant`) }) + + it(`merges assistant text and tool_call into a single assistant message`, () => { + const messages: Array = [ + { role: `user`, content: `Help me` }, + { role: `assistant`, content: `Let me look that up` }, + { + role: `tool_call`, + content: `lookup`, + toolCallId: `tc-0`, + toolName: `lookup`, + toolArgs: { q: `hello` }, + }, + { + role: `tool_result`, + content: `found it`, + toolCallId: `tc-0`, + isError: false, + }, + ] + + const history = toAgentHistory(messages) + + // The assistant text and tool_call should be merged into one assistant + // message, otherwise the Claude API rejects consecutive assistant messages + // and tool_result can't find its matching tool_use in the previous message. + const assistantMessages = history.filter((m) => m.role === `assistant`) + expect(assistantMessages).toHaveLength(1) + + const assistant = assistantMessages[0] as AssistantMessage + expect(assistant.content).toHaveLength(2) + expect(assistant.content[0]).toMatchObject({ + type: `text`, + text: `Let me look that up`, + }) + expect(assistant.content[1]).toMatchObject({ + type: `toolCall`, + id: `tc-0`, + name: `lookup`, + }) + }) + + it(`handles interleaved tool_call/tool_result pairs without consecutive assistants`, () => { + const messages: Array = [ + { role: `user`, content: `Do two things` }, + { role: `assistant`, content: `I will do both` }, + { + role: `tool_call`, + content: `{}`, + toolCallId: `tc-0`, + toolName: `tool_a`, + toolArgs: {}, + }, + { + role: `tool_result`, + content: `result a`, + toolCallId: `tc-0`, + isError: false, + }, + { + role: `tool_call`, + content: `{}`, + toolCallId: `tc-1`, + toolName: `tool_b`, + toolArgs: {}, + }, + { + role: `tool_result`, + content: `result b`, + toolCallId: `tc-1`, + isError: false, + }, + ] + + const history = toAgentHistory(messages) + + // First tool call should be merged with the preceding text + const first = history[1] as AssistantMessage + expect(first.role).toBe(`assistant`) + expect(first.content).toHaveLength(2) + expect(first.content[0]).toMatchObject({ type: `text` }) + expect(first.content[1]).toMatchObject({ type: `toolCall`, id: `tc-0` }) + + // No consecutive assistant messages + for (let i = 1; i < history.length; i++) { + if (history[i].role === `assistant`) { + expect(history[i - 1].role).not.toBe(`assistant`) + } + } + + // Each tool_result should still be present + const toolResults = history.filter((m) => m.role === `toolResult`) + expect(toolResults).toHaveLength(2) + }) + + it(`does not produce consecutive assistant messages across multi-step runs`, () => { + const messages: Array = [ + { role: `user`, content: `Help` }, + // Step 1: text + tool call + { role: `assistant`, content: `Step 1` }, + { + role: `tool_call`, + content: `{}`, + toolCallId: `tc-0`, + toolName: `search`, + toolArgs: {}, + }, + { + role: `tool_result`, + content: `found`, + toolCallId: `tc-0`, + isError: false, + }, + // Step 2: text + tool call + { role: `assistant`, content: `Step 2` }, + { + role: `tool_call`, + content: `{}`, + toolCallId: `tc-1`, + toolName: `write`, + toolArgs: {}, + }, + { + role: `tool_result`, + content: `done`, + toolCallId: `tc-1`, + isError: false, + }, + // Step 3: final answer + { role: `assistant`, content: `All done` }, + ] + + const history = toAgentHistory(messages) + + // Verify no consecutive assistant messages + for (let i = 1; i < history.length; i++) { + if (history[i].role === `assistant`) { + expect(history[i - 1].role).not.toBe(`assistant`) + } + } + }) }) diff --git a/packages/agents-runtime/test/runtime-dsl.test.ts b/packages/agents-runtime/test/runtime-dsl.test.ts index 9d8290b3c2..84094c0a8e 100644 --- a/packages/agents-runtime/test/runtime-dsl.test.ts +++ b/packages/agents-runtime/test/runtime-dsl.test.ts @@ -451,18 +451,23 @@ function createFakeToolAssistant(opts?: { if (trimmed.startsWith(`sync_echo `)) { const text = trimmed.slice(`sync_echo `.length) - bridge.onToolCallStart(`sync_echo`, { text }) + bridge.onToolCallStart(`call-sync_echo`, `sync_echo`, { text }) const result = { echoed: text } - bridge.onToolCallEnd(`sync_echo`, result, false) + bridge.onToolCallEnd(`call-sync_echo`, `sync_echo`, result, false) return `sync_echo: ${text}` } if (trimmed.startsWith(`async_lookup `)) { const key = trimmed.slice(`async_lookup `.length) - bridge.onToolCallStart(`async_lookup`, { key }) + bridge.onToolCallStart(`call-async_lookup`, `async_lookup`, { key }) await new Promise((resolve) => setTimeout(resolve, 5)) const result = { key, value: `lookup:${key}` } - bridge.onToolCallEnd(`async_lookup`, result, false) + bridge.onToolCallEnd( + `call-async_lookup`, + `async_lookup`, + result, + false + ) return `async_lookup: lookup:${key}` } @@ -470,7 +475,7 @@ function createFakeToolAssistant(opts?: { const match = trimmed.match(/^stateful_note write (\S+)\s+(.+)$/) const key = match?.[1] ?? `` const text = match?.[2] ?? `` - bridge.onToolCallStart(`stateful_note`, { + bridge.onToolCallStart(`call-stateful_note`, `stateful_note`, { action: `write`, key, text, @@ -486,6 +491,7 @@ function createFakeToolAssistant(opts?: { } } bridge.onToolCallEnd( + `call-stateful_note`, `stateful_note`, { action: `write`, key, text }, false @@ -495,9 +501,13 @@ function createFakeToolAssistant(opts?: { if (trimmed.startsWith(`stateful_note read `)) { const key = trimmed.slice(`stateful_note read `.length) - bridge.onToolCallStart(`stateful_note`, { action: `read`, key }) + bridge.onToolCallStart(`call-stateful_note`, `stateful_note`, { + action: `read`, + key, + }) const text = opts?.notes?.get(key)?.text ?? `` bridge.onToolCallEnd( + `call-stateful_note`, `stateful_note`, { action: `read`, key, text }, false @@ -507,8 +517,13 @@ function createFakeToolAssistant(opts?: { if (trimmed.startsWith(`fail_tool `)) { const reason = trimmed.slice(`fail_tool `.length) - bridge.onToolCallStart(`fail_tool`, { reason }) - bridge.onToolCallEnd(`fail_tool`, `fail_tool: ${reason}`, true) + bridge.onToolCallStart(`call-fail_tool`, `fail_tool`, { reason }) + bridge.onToolCallEnd( + `call-fail_tool`, + `fail_tool`, + `fail_tool: ${reason}`, + true + ) return `fail_tool error: ${reason}` } @@ -841,7 +856,10 @@ function createDispatcherAssistant(ctx: HandlerContext): TestAgentSpec { const countRow = counters.get(`dispatchCount`) const dispatchCount = (countRow?.value ?? 0) + 1 - bridge.onToolCallStart(`dispatch`, { type: targetKind, task }) + bridge.onToolCallStart(`call-dispatch`, `dispatch`, { + type: targetKind, + task, + }) if (countRow) { counters.update(`dispatchCount`, (draft) => { @@ -877,7 +895,12 @@ function createDispatcherAssistant(ctx: HandlerContext): TestAgentSpec { draft.value = `idle` }) - bridge.onToolCallEnd(`dispatch`, { type: targetKind, childId }, false) + bridge.onToolCallEnd( + `call-dispatch`, + `dispatch`, + { type: targetKind, childId }, + false + ) return fullText || `(no text output)` }, }) @@ -907,7 +930,11 @@ function createManagerWorkerAssistant(ctx: HandlerContext): TestAgentSpec { if (trimmed.startsWith(`spawn_perspectives `)) { const question = trimmed.slice(`spawn_perspectives `.length) const parentId = entityIdFromUrl(ctx.entityUrl) - bridge.onToolCallStart(`spawn_perspectives`, { question }) + bridge.onToolCallStart( + `call-spawn_perspectives`, + `spawn_perspectives`, + { question } + ) status.update(`current`, (draft: Record) => { draft.value = `spawning` @@ -942,6 +969,7 @@ function createManagerWorkerAssistant(ctx: HandlerContext): TestAgentSpec { }) bridge.onToolCallEnd( + `call-spawn_perspectives`, `spawn_perspectives`, { spawned: perspectives.map((perspective) => perspective.id) }, false @@ -950,10 +978,15 @@ function createManagerWorkerAssistant(ctx: HandlerContext): TestAgentSpec { } if (trimmed === `wait_for_all`) { - bridge.onToolCallStart(`wait_for_all`, {}) + bridge.onToolCallStart(`call-wait_for_all`, `wait_for_all`, {}) if (children.toArray.length === 0) { - bridge.onToolCallEnd(`wait_for_all`, { error: true }, true) + bridge.onToolCallEnd( + `call-wait_for_all`, + `wait_for_all`, + { error: true }, + true + ) return `No perspective agents have been spawned yet.` } @@ -981,6 +1014,7 @@ function createManagerWorkerAssistant(ctx: HandlerContext): TestAgentSpec { }) bridge.onToolCallEnd( + `call-wait_for_all`, `wait_for_all`, { collected: results.length }, false @@ -1014,7 +1048,7 @@ function createMapReduceAssistant(ctx: HandlerContext): TestAgentSpec { }>(ctx.db, `status`) const parentId = entityIdFromUrl(ctx.entityUrl) - bridge.onToolCallStart(`map_chunks`, { + bridge.onToolCallStart(`call-map_chunks`, `map_chunks`, { task, chunkCount: chunkSpecs.length, }) @@ -1059,7 +1093,12 @@ function createMapReduceAssistant(ctx: HandlerContext): TestAgentSpec { status.update(`current`, (draft: Record) => { draft.value = `idle` }) - bridge.onToolCallEnd(`map_chunks`, { chunkCount: results.length }, false) + bridge.onToolCallEnd( + `call-map_chunks`, + `map_chunks`, + { chunkCount: results.length }, + false + ) return results .map((result, index) => `chunk-${index + 1}:${result}`) @@ -1090,7 +1129,7 @@ function createPipelineAssistant(ctx: HandlerContext): TestAgentSpec { const pipeline = buildStateProxy(ctx.db, `pipeline`) const parentId = entityIdFromUrl(ctx.entityUrl) - bridge.onToolCallStart(`run_pipeline`, { + bridge.onToolCallStart(`call-run_pipeline`, `run_pipeline`, { input, stageCount: stages.length, }) @@ -1142,7 +1181,12 @@ function createPipelineAssistant(ctx: HandlerContext): TestAgentSpec { }) ) - bridge.onToolCallEnd(`run_pipeline`, { stageCount: stages.length }, false) + bridge.onToolCallEnd( + `call-run_pipeline`, + `run_pipeline`, + { stageCount: stages.length }, + false + ) return currentInput }, }) @@ -1184,7 +1228,7 @@ function createResearchAssistant(ctx: HandlerContext): TestAgentSpec { .map((part) => part.trim()) .filter(Boolean) - bridge.onToolCallStart(`spawn_researchers`, { + bridge.onToolCallStart(`call-spawn_researchers`, `spawn_researchers`, { topic, researcherCount: subtopics.length, }) @@ -1216,15 +1260,25 @@ function createResearchAssistant(ctx: HandlerContext): TestAgentSpec { status.update(`current`, (draft: Record) => { draft.value = `waiting` }) - bridge.onToolCallEnd(`spawn_researchers`, { topic, subtopics }, false) + bridge.onToolCallEnd( + `call-spawn_researchers`, + `spawn_researchers`, + { topic, subtopics }, + false + ) return `spawned_researchers:${subtopics.join(`,`)}` } if (trimmed === `wait_for_results`) { - bridge.onToolCallStart(`wait_for_results`, {}) + bridge.onToolCallStart(`call-wait_for_results`, `wait_for_results`, {}) if (children.toArray.length === 0) { - bridge.onToolCallEnd(`wait_for_results`, { error: true }, true) + bridge.onToolCallEnd( + `call-wait_for_results`, + `wait_for_results`, + { error: true }, + true + ) return `No researcher agents have been spawned yet.` } @@ -1247,6 +1301,7 @@ function createResearchAssistant(ctx: HandlerContext): TestAgentSpec { draft.value = `idle` }) bridge.onToolCallEnd( + `call-wait_for_results`, `wait_for_results`, { resultCount: results.length }, false @@ -1306,7 +1361,9 @@ function createPeerReviewAssistant( if (trimmed.startsWith(`start_review `)) { const artifact = trimmed.slice(`start_review `.length) - bridge.onToolCallStart(`start_review`, { artifact }) + bridge.onToolCallStart(`call-start_review`, `start_review`, { + artifact, + }) status.update(`current`, (draft: Record) => { draft.value = `reviewing` }) @@ -1337,6 +1394,7 @@ function createPeerReviewAssistant( } bridge.onToolCallEnd( + `call-start_review`, `start_review`, { reviewers: reviewers.map((reviewer) => reviewer.id) }, false @@ -1345,13 +1403,22 @@ function createPeerReviewAssistant( } if (trimmed === `summarize_reviews`) { - bridge.onToolCallStart(`summarize_reviews`, {}) + bridge.onToolCallStart( + `call-summarize_reviews`, + `summarize_reviews`, + {} + ) const rows = reviewers .map((reviewer) => shared.reviews.get(`review-${reviewer.reviewer}`)) .filter(Boolean) as Array if (rows.length === 0) { - bridge.onToolCallEnd(`summarize_reviews`, { count: 0 }, true) + bridge.onToolCallEnd( + `call-summarize_reviews`, + `summarize_reviews`, + { count: 0 }, + true + ) return `No reviews have been written yet.` } @@ -1368,7 +1435,12 @@ function createPeerReviewAssistant( status.update(`current`, (draft: Record) => { draft.value = `done` }) - bridge.onToolCallEnd(`summarize_reviews`, { count: rows.length }, false) + bridge.onToolCallEnd( + `call-summarize_reviews`, + `summarize_reviews`, + { count: rows.length }, + false + ) return `average:${average.toFixed(1)};count:${rows.length};${ordered}` } @@ -1450,13 +1522,18 @@ function createDebateAssistant( if (trimmed.startsWith(`start_debate `)) { const topic = trimmed.slice(`start_debate `.length) - bridge.onToolCallStart(`start_debate`, { topic }) + bridge.onToolCallStart(`call-start_debate`, `start_debate`, { topic }) status.update(`current`, (draft: Record) => { draft.value = `debating` }) await spawnDebaters(debaters, topic) - bridge.onToolCallEnd(`start_debate`, { topic }, false) + bridge.onToolCallEnd( + `call-start_debate`, + `start_debate`, + { topic }, + false + ) return `started:${topic}` } @@ -1473,22 +1550,32 @@ function createDebateAssistant( return `invalid:start_side` } - bridge.onToolCallStart(`start_side`, { side, topic }) + bridge.onToolCallStart(`call-start_side`, `start_side`, { side, topic }) status.update(`current`, (draft: Record) => { draft.value = `debating` }) await spawnDebaters([debater], topic) - bridge.onToolCallEnd(`start_side`, { side, topic }, false) + bridge.onToolCallEnd( + `call-start_side`, + `start_side`, + { side, topic }, + false + ) return `started:${side}:${topic}` } if (trimmed === `end_debate`) { - bridge.onToolCallStart(`end_debate`, {}) + bridge.onToolCallStart(`call-end_debate`, `end_debate`, {}) const pro = shared.arguments.get(`pro-1`) const con = shared.arguments.get(`con-1`) if (!pro || !con) { - bridge.onToolCallEnd(`end_debate`, { count: 0 }, true) + bridge.onToolCallEnd( + `call-end_debate`, + `end_debate`, + { count: 0 }, + true + ) return `No debate arguments have been recorded yet.` } @@ -1499,7 +1586,12 @@ function createDebateAssistant( status.update(`current`, (draft: Record) => { draft.value = `done` }) - bridge.onToolCallEnd(`end_debate`, { count: 2 }, false) + bridge.onToolCallEnd( + `call-end_debate`, + `end_debate`, + { count: 2 }, + false + ) return [`winner:pro`, `pro:${pro.text}`, `con:${con.text}`].join(`;`) } @@ -1555,7 +1647,7 @@ function createWikiAssistant( .map((part) => part.trim()) .filter(Boolean) - bridge.onToolCallStart(`create_wiki`, { + bridge.onToolCallStart(`call-create_wiki`, `create_wiki`, { topic, specialistCount: subtopics.length, }) @@ -1563,6 +1655,7 @@ function createWikiAssistant( const existingMeta = meta.get(`wiki`) if (existingMeta && existingMeta.topic !== topic) { bridge.onToolCallEnd( + `call-create_wiki`, `create_wiki`, { topic, existingTopic: existingMeta.topic }, true @@ -1613,6 +1706,7 @@ function createWikiAssistant( } bridge.onToolCallEnd( + `call-create_wiki`, `create_wiki`, { topic, subtopics, spawned, reused }, false @@ -1622,25 +1716,35 @@ function createWikiAssistant( if (trimmed.startsWith(`query_wiki `)) { const query = trimmed.slice(`query_wiki `.length) - bridge.onToolCallStart(`query_wiki`, { query }) + bridge.onToolCallStart(`call-query_wiki`, `query_wiki`, { query }) const rows = [...shared.articles.toArray].sort((left, right) => left.key.localeCompare(right.key) ) if (rows.length === 0) { - bridge.onToolCallEnd(`query_wiki`, { articleCount: 0 }, false) + bridge.onToolCallEnd( + `call-query_wiki`, + `query_wiki`, + { articleCount: 0 }, + false + ) return `No wiki articles have been written yet.` } const summary = rows.map((row) => `${row.key}:${row.topic}`).join(`;`) - bridge.onToolCallEnd(`query_wiki`, { articleCount: rows.length }, false) + bridge.onToolCallEnd( + `call-query_wiki`, + `query_wiki`, + { articleCount: rows.length }, + false + ) return `articles:${rows.length};${summary}` } if (trimmed === `get_wiki_status`) { - bridge.onToolCallStart(`get_wiki_status`, {}) + bridge.onToolCallStart(`call-get_wiki_status`, `get_wiki_status`, {}) const rows = [...children.toArray] const completeCount = shared.articles.toArray.length const pending = rows @@ -1648,6 +1752,7 @@ function createWikiAssistant( .map((row) => row.kind ?? row.key) .join(`,`) bridge.onToolCallEnd( + `call-get_wiki_status`, `get_wiki_status`, { complete: completeCount, diff --git a/packages/agents-runtime/test/use-context-budget.test.ts b/packages/agents-runtime/test/use-context-budget.test.ts index 8f968356c8..4b18422501 100644 --- a/packages/agents-runtime/test/use-context-budget.test.ts +++ b/packages/agents-runtime/test/use-context-budget.test.ts @@ -62,6 +62,102 @@ describe(`budget enforcement`, () => { ) }) + it(`stubs oversized tool_result content instead of dropping it`, async () => { + const messages = await assembleContext({ + sourceBudget: 100, + sources: { + self: { + content: () => [ + { role: `user` as const, content: `Hi`, at: 1 }, + { role: `assistant` as const, content: `Let me check`, at: 2 }, + { + role: `tool_call` as const, + content: `search`, + toolCallId: `tc-1`, + toolName: `search`, + toolArgs: { q: `hello` }, + at: 3, + }, + { + role: `tool_result` as const, + content: `x`.repeat(5000), + toolCallId: `tc-1`, + isError: false, + at: 4, + }, + { + role: `assistant` as const, + content: `Here is the answer`, + at: 5, + }, + ], + max: 100_000, + cache: `volatile`, + }, + }, + }) + + const toolCalls = messages.filter((m) => m.role === `tool_call`) + const toolResults = messages.filter((m) => m.role === `tool_result`) + + expect(toolCalls).toHaveLength(1) + expect(toolResults).toHaveLength(1) + expect((toolCalls[0] as any).toolCallId).toBe(`tc-1`) + expect((toolResults[0] as any).toolCallId).toBe(`tc-1`) + expect(toolResults[0]!.content).toMatch(/\[content truncated/) + expect(toolResults[0]!.content).toMatch(/load_timeline_range/) + }) + + it(`drops orphaned tool_results when their tool_call is budget-truncated`, async () => { + const messages = await assembleContext({ + sourceBudget: 30, + sources: { + self: { + content: () => [ + { role: `assistant` as const, content: `I will search`, at: 1 }, + { + role: `tool_call` as const, + content: `search`, + toolCallId: `tc-old`, + toolName: `search`, + toolArgs: {}, + at: 2, + }, + { + role: `tool_result` as const, + content: `found`, + toolCallId: `tc-old`, + isError: false, + at: 3, + }, + { + role: `assistant` as const, + content: `Here is the answer`, + at: 4, + }, + { role: `user` as const, content: `Thanks`, at: 5 }, + ], + max: 100_000, + cache: `volatile`, + }, + }, + }) + + const toolCalls = messages.filter((m) => m.role === `tool_call`) + const toolResults = messages.filter((m) => m.role === `tool_result`) + + for (const tr of toolResults) { + const trId = (tr as any).toolCallId + expect(toolCalls.some((tc) => (tc as any).toolCallId === trId)).toBe(true) + } + for (const tc of toolCalls) { + const tcId = (tc as any).toolCallId + expect(toolResults.some((tr) => (tr as any).toolCallId === tcId)).toBe( + true + ) + } + }) + it(`does not write a stream event on overflow`, async () => { const logger = vi.fn() await assembleContext( diff --git a/packages/agents-server-ui/package.json b/packages/agents-server-ui/package.json index 3852a54bb2..76d86d8ea7 100644 --- a/packages/agents-server-ui/package.json +++ b/packages/agents-server-ui/package.json @@ -16,6 +16,7 @@ "@durable-streams/client": "npm:@electric-ax/durable-streams-client-beta@^0.3.1", "@durable-streams/state": "npm:@electric-ax/durable-streams-state-beta@^0.3.1", "@electric-ax/agents-runtime": "workspace:*", + "@fontsource-variable/figtree": "^5.2.10", "@streamdown/math": "^1.0.2", "@tanstack/db": "^0.6.4", "@tanstack/electric-db-collection": "^0.3.2", diff --git a/packages/agents-server-ui/src/components/AgentResponse.module.css b/packages/agents-server-ui/src/components/AgentResponse.module.css index 57102548a6..10ea0b383e 100644 --- a/packages/agents-server-ui/src/components/AgentResponse.module.css +++ b/packages/agents-server-ui/src/components/AgentResponse.module.css @@ -12,9 +12,11 @@ } .doneText { - opacity: 0.5; + color: var(--ds-text-4, var(--ds-text-3)); + opacity: 0.8; } .timeText { - opacity: 0.4; + color: var(--ds-text-4, var(--ds-text-3)); + opacity: 0.7; } diff --git a/packages/agents-server-ui/src/components/EntityTimeline.module.css b/packages/agents-server-ui/src/components/EntityTimeline.module.css index b39c9de612..0a46878db2 100644 --- a/packages/agents-server-ui/src/components/EntityTimeline.module.css +++ b/packages/agents-server-ui/src/components/EntityTimeline.module.css @@ -55,7 +55,7 @@ * CSS variables so EntityTimeline + MessageInput can stay perfectly * aligned without duplicating constants. */ .content { - padding: 32px 40px; + padding: 36px 40px; max-width: calc(var(--chat-surface-width) + 80px); margin: 0 auto; overflow-anchor: none; @@ -64,9 +64,9 @@ } .statusPill { - padding: 4px 14px; - border-radius: 12px; - opacity: 0.5; + padding: 2px 0 2px 10px; + border-left: 2px solid var(--ds-gray-a3); + color: var(--ds-text-4, var(--ds-text-3)); letter-spacing: 0.02em; } @@ -89,22 +89,25 @@ .jumpToBottom { position: absolute; bottom: 24px; - left: 50%; - transform: translateX(-50%); + right: 24px; z-index: 10; display: inline-flex; align-items: center; justify-content: center; - width: 32px; - height: 32px; - border: none; + width: 28px; + height: 28px; + border: 1px solid var(--ds-gray-a3); border-radius: 9999px; - background: var(--ds-gray-12); - color: var(--ds-gray-1); + background: var(--ds-surface); + color: var(--ds-gray-9); cursor: pointer; - box-shadow: var(--ds-shadow-3); - transition: background 120ms ease; + box-shadow: var(--ds-shadow-2); + opacity: 0.85; + transition: + opacity 120ms ease, + background 120ms ease; } .jumpToBottom:hover { - background: var(--ds-gray-11); + opacity: 1; + background: var(--ds-gray-2); } diff --git a/packages/agents-server-ui/src/components/EntityTimeline.tsx b/packages/agents-server-ui/src/components/EntityTimeline.tsx index eaaa31948a..b0a06a424b 100644 --- a/packages/agents-server-ui/src/components/EntityTimeline.tsx +++ b/packages/agents-server-ui/src/components/EntityTimeline.tsx @@ -414,7 +414,7 @@ export function EntityTimeline({ scrollbars="vertical" >
- + {spawnTime ? ( @@ -477,7 +477,7 @@ export function EntityTimeline({ )} {entityStopped && ( - + stopped diff --git a/packages/agents-server-ui/src/components/MarkdownCodeBlock.tsx b/packages/agents-server-ui/src/components/MarkdownCodeBlock.tsx index 3ee5d90727..83cca61ea4 100644 --- a/packages/agents-server-ui/src/components/MarkdownCodeBlock.tsx +++ b/packages/agents-server-ui/src/components/MarkdownCodeBlock.tsx @@ -223,22 +223,25 @@ function FencedCodeBlock({
           {tokens ? (
             
-              {tokens.tokens.map((line, i) => (
-                
-                  {line.length === 0
-                    ? // Empty lines need at least a non-breaking space
-                      // so the row still has a baseline + visible height.
-                      `\u00A0`
-                    : line.map((token, j) => (
-                        
-                          {token.content}
-                        
-                      ))}
-                
-              ))}
+              {tokens.tokens
+                .filter(
+                  (line, i) =>
+                    !(i === tokens.tokens.length - 1 && line.length === 0)
+                )
+                .map((line, i) => (
+                  
+                    {line.length === 0
+                      ? `\u00A0`
+                      : line.map((token, j) => (
+                          
+                            {token.content}
+                          
+                        ))}
+                  
+                ))}
             
           ) : (
             {codeText}
@@ -428,12 +431,15 @@ function MermaidBlock({
 // switch colours per theme; we just have to forward them as
 // inline-style props.
 function tokenStyle(
-  color: string | undefined,
+  _color: string | undefined,
   htmlStyle: Record | undefined
 ): React.CSSProperties {
   const out: Record = {}
-  if (color) out.color = color
-  if (htmlStyle) Object.assign(out, htmlStyle)
+  if (htmlStyle) {
+    for (const [k, v] of Object.entries(htmlStyle)) {
+      if (k !== `color`) out[k] = v
+    }
+  }
   return out as React.CSSProperties
 }
 
diff --git a/packages/agents-server-ui/src/components/MessageInput.module.css b/packages/agents-server-ui/src/components/MessageInput.module.css
index efcc980b16..07ae437a3a 100644
--- a/packages/agents-server-ui/src/components/MessageInput.module.css
+++ b/packages/agents-server-ui/src/components/MessageInput.module.css
@@ -34,8 +34,11 @@
      bottom of a scrolling chat surface and chat content must NOT
      bleed through it. Solid raised surface is the right semantic. */
   background: var(--ds-surface-raised);
-  border: 1px solid var(--ds-gray-a4);
+  border: 1px solid var(--ds-border-1);
   border-radius: 12px;
+  box-shadow:
+    0 1px 3px rgba(15, 15, 30, 0.04),
+    0 1px 1px rgba(15, 15, 30, 0.02);
   /* 12px on all sides — same as the user-message bubble (`Stack p={3}`
      in UserMessage.tsx, where `--ds-space-3 = 12px`). Keeping the
      two surfaces on the same padding makes the textarea text column
@@ -97,7 +100,7 @@
 }
 
 .sendIcon {
-  color: var(--ds-gray-8);
+  color: var(--ds-gray-7);
   cursor: default;
   transition: color 0.15s ease;
   flex-shrink: 0;
diff --git a/packages/agents-server-ui/src/components/NewSessionPage.module.css b/packages/agents-server-ui/src/components/NewSessionPage.module.css
index fa7b5ddc3a..5e6a489e0d 100644
--- a/packages/agents-server-ui/src/components/NewSessionPage.module.css
+++ b/packages/agents-server-ui/src/components/NewSessionPage.module.css
@@ -22,17 +22,21 @@
   display: flex;
   flex-direction: column;
   gap: var(--ds-space-5);
+  align-items: center;
 }
 
 .heading {
   display: flex;
   flex-direction: column;
-  gap: var(--ds-space-1);
+  align-items: center;
+  gap: var(--ds-space-2);
+  padding-top: var(--ds-space-7);
 }
 
 .headingTitle {
   font-weight: 400;
   margin: 0;
+  transition: opacity 0.3s ease;
 }
 
 .headingSubtitle {
@@ -143,13 +147,16 @@
   gap: var(--ds-space-2);
   padding: var(--ds-space-3) var(--ds-space-4);
   background: var(--ds-input-bg);
-  border: 1px solid var(--ds-gray-a4);
+  border: 1px solid var(--ds-border-2);
   border-radius: var(--ds-radius-4);
-  transition: border-color 0.15s ease;
+  transition:
+    border-color 0.15s ease,
+    box-shadow 0.15s ease;
   margin: 0 calc(-1 * var(--ds-space-4));
 }
 .composer:focus-within {
-  border-color: var(--ds-accent-a6);
+  border-color: var(--ds-accent-a5);
+  box-shadow: 0 0 0 3px var(--ds-accent-a2);
 }
 .composerDisabled {
   opacity: 0.6;
@@ -227,14 +234,17 @@
   border-radius: var(--ds-radius-2);
   font-size: var(--ds-text-xs);
   color: var(--ds-text-2);
-  background: var(--ds-gray-a3);
+  background: var(--ds-gray-a4);
+  border: 1px solid var(--ds-gray-a4);
   cursor: pointer;
   transition:
     background 0.1s ease,
+    border-color 0.1s ease,
     color 0.1s ease;
 }
 .pill:hover {
-  background: var(--ds-gray-a4);
+  background: var(--ds-gray-a5);
+  border-color: var(--ds-gray-a6);
   color: var(--ds-text-1);
 }
 .pillButton {
@@ -273,30 +283,80 @@
   color: var(--ds-accent-10);
 }
 
-/* Project picker -------------------------------------------------- */
+/* Hero project picker ---------------------------------------------- */
 
-.projectPicker {
-  display: flex;
-  flex-direction: column;
+.projectTrigger {
+  all: unset;
+  display: inline-flex;
+  align-items: center;
   gap: var(--ds-space-1);
+  cursor: pointer;
+  color: var(--ds-text-3);
+  font-size: var(--ds-text-lg);
+  font-weight: 400;
+  font-family: var(--ds-font-body);
+  transition: color 0.12s ease;
 }
-.projectPickerLabel {
-  text-transform: uppercase;
-  letter-spacing: 0.06em;
-  font-size: 10px;
+.projectTrigger:hover {
+  color: var(--ds-text-2);
+}
+.projectTriggerChevron {
+  flex-shrink: 0;
+  transition: transform 0.15s ease;
+}
+.projectTriggerChevron[data-open='true'] {
+  transform: rotate(180deg);
 }
-.projectPickerRow {
+
+.projectPopover {
+  min-width: 240px;
+}
+.projectPopoverHeader {
+  padding: var(--ds-space-2) var(--ds-space-3);
+  font-size: var(--ds-text-xs);
+  color: var(--ds-text-3);
+}
+.projectItem {
+  all: unset;
   display: flex;
   align-items: center;
   gap: var(--ds-space-2);
-  flex-wrap: wrap;
+  width: 100%;
+  padding: var(--ds-space-2) var(--ds-space-3);
+  border-radius: 7px;
+  cursor: pointer;
+  font-size: var(--ds-text-sm);
+  color: var(--ds-text-1);
+  box-sizing: border-box;
+  transition: background 0.1s ease;
+}
+.projectItem:hover {
+  background: var(--ds-gray-a2);
+}
+.projectItemIcon {
+  color: var(--ds-text-3);
+  flex-shrink: 0;
+}
+.projectItemCheck {
+  margin-left: auto;
+  color: var(--ds-text-3);
+  flex-shrink: 0;
+}
+
+.projectCreateInline {
+  display: flex;
+  flex-direction: column;
+  gap: var(--ds-space-2);
+  padding: var(--ds-space-2) var(--ds-space-3);
 }
-.projectCreateForm {
+.projectCreateRow {
   display: flex;
   align-items: center;
   gap: var(--ds-space-2);
 }
 .projectCreateInput {
+  flex: 1;
+  min-width: 0;
   border: 1px solid var(--ds-border-1);
   border-radius: var(--ds-radius-2);
   padding: 4px 8px;
@@ -310,20 +370,14 @@
 .projectCreateInput:focus {
   border-color: var(--ds-accent-a6);
 }
-.projectCreateBtn {
-  all: unset;
-  cursor: pointer;
-  font-size: var(--ds-text-sm);
-  color: var(--ds-accent-9);
-  padding: 4px 8px;
-  border-radius: var(--ds-radius-2);
-}
-.projectCreateBtn:hover {
-  background: var(--ds-accent-a2);
+.projectPathInput {
+  composes: projectCreateInput;
+  font-family: var(--ds-font-mono);
+  font-size: var(--ds-text-xs);
 }
-.projectCreateBtn:disabled {
-  cursor: not-allowed;
-  opacity: 0.5;
+.projectPathError {
+  font-size: var(--ds-text-xs);
+  color: var(--ds-red-11);
 }
 
 /* Other-agents section under the composer ----------------------- */
diff --git a/packages/agents-server-ui/src/components/NewSessionPage.tsx b/packages/agents-server-ui/src/components/NewSessionPage.tsx
index 14cd5f1c10..03ff85bbba 100644
--- a/packages/agents-server-ui/src/components/NewSessionPage.tsx
+++ b/packages/agents-server-ui/src/components/NewSessionPage.tsx
@@ -1,5 +1,18 @@
-import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'
-import { ArrowUp } from 'lucide-react'
+import {
+  useCallback,
+  useEffect,
+  useLayoutEffect,
+  useMemo,
+  useRef,
+  useState,
+} from 'react'
+import {
+  ArrowUp,
+  Check,
+  ChevronDown,
+  FolderOpen,
+  FolderPlus,
+} from 'lucide-react'
 import { useLiveQuery } from '@tanstack/react-db'
 import { eq, not } from '@tanstack/db'
 import { useNavigate } from '@tanstack/react-router'
@@ -7,22 +20,39 @@ import { nanoid } from 'nanoid'
 import { useElectricAgents } from '../lib/ElectricAgentsProvider'
 import { useServerConnection } from '../hooks/useServerConnection'
 import { useProjects } from '../hooks/useProjects'
-import { Select, Stack, Text } from '../ui'
+import { Button, Popover, Select, Stack, Text } from '../ui'
 import { MainHeader } from './MainHeader'
 import { SchemaForm, hasSchemaProperties, isObjectSchema } from './SchemaForm'
 import styles from './NewSessionPage.module.css'
 import type { ElectricEntityType } from '../lib/ElectricAgentsProvider'
+import type { Project } from '../hooks/useProjects'
 
-/**
- * The "default agent" — when an entity type with this name is registered
- * we surface a chat-input quick-start at the top of the new-session page
- * so the most common flow is one keystroke away.
- *
- * TODO: replace this with a server-side flag (e.g. tags.default) once
- * the entity_types schema gets one.
- */
 const DEFAULT_AGENT_NAME = `horton`
 
+const HERO_VERBS = [
+  `Let’s ship`,
+  `Let’s create`,
+  `Let’s build`,
+  `Let’s explore`,
+  `Let’s debug`,
+  `Let’s design`,
+  `Let’s hack`,
+  `Let’s improve`,
+]
+
+function useRotatingVerb(): string {
+  const [index, setIndex] = useState(() =>
+    Math.floor(Math.random() * HERO_VERBS.length)
+  )
+  useEffect(() => {
+    const id = setInterval(() => {
+      setIndex((prev) => (prev + 1) % HERO_VERBS.length)
+    }, 4_000)
+    return () => clearInterval(id)
+  }, [])
+  return HERO_VERBS[index]
+}
+
 interface SchemaProperty {
   type?: string
   enum?: Array
@@ -31,24 +61,25 @@ interface SchemaProperty {
   description?: string
 }
 
-/**
- * "New session" page shown at `/`.
- *
- * If a `horton` entity type is available we render a chat-style
- * composer at the top of the page so the user can just type and hit
- * Enter to start a new conversation. Other agent types are listed
- * below as cards. Picking one of those either spawns immediately
- * (no schema) or transitions to an inline form.
- */
 export function NewSessionPage(): React.ReactElement {
   const navigate = useNavigate()
   const { entityTypesCollection, spawnEntity } = useElectricAgents()
   const { activeServer } = useServerConnection()
-  const { projects, activeProjectId, setActiveProjectId, createProject } =
-    useProjects()
+  const {
+    projects,
+    activeProjectId,
+    setActiveProjectId,
+    createProject,
+    validatePath,
+  } = useProjects()
   const [selected, setSelected] = useState(null)
   const [error, setError] = useState(null)
 
+  const activeProject = useMemo(
+    () => projects.find((p) => p.id === activeProjectId) ?? null,
+    [projects, activeProjectId]
+  )
+
   const { data: entityTypes = [] } = useLiveQuery(
     (query) => {
       if (!entityTypesCollection) return undefined
@@ -71,12 +102,6 @@ export function NewSessionPage(): React.ReactElement {
 
   const baseUrl = activeServer?.url ?? null
 
-  /**
-   * Spawn an entity, optionally followed by a `/send` of an initial
-   * user message. We prefer this two-step over `initialMessage` on
-   * spawn so the message goes through the same path as the regular
-   * MessageInput (which is the proven path that wakes horton).
-   */
   const doSpawn = useCallback(
     async (
       typeName: string,
@@ -89,7 +114,15 @@ export function NewSessionPage(): React.ReactElement {
       const tags: Record | undefined = activeProjectId
         ? { project: activeProjectId }
         : undefined
-      const tx = spawnEntity({ type: typeName, name, args, tags })
+      const spawnArgs = activeProject?.path
+        ? { ...args, workingDirectory: activeProject.path }
+        : args
+      const tx = spawnEntity({
+        type: typeName,
+        name,
+        args: spawnArgs,
+        tags,
+      })
       navigate({
         to: `/entity/$`,
         params: { _splat: `${typeName}/${name}` },
@@ -116,7 +149,7 @@ export function NewSessionPage(): React.ReactElement {
         )
       }
     },
-    [navigate, spawnEntity, baseUrl, activeProjectId]
+    [navigate, spawnEntity, baseUrl, activeProjectId, activeProject]
   )
 
   const handleSelectType = useCallback(
@@ -159,9 +192,10 @@ export function NewSessionPage(): React.ReactElement {
               spawnReady={Boolean(spawnEntity)}
               error={error}
               projects={projects}
-              activeProjectId={activeProjectId}
+              activeProject={activeProject}
               onChangeProject={setActiveProjectId}
               onCreateProject={createProject}
+              onValidatePath={validatePath}
             />
           )}
         
@@ -178,9 +212,10 @@ function Picker({ spawnReady, error, projects, - activeProjectId, + activeProject, onChangeProject, onCreateProject, + onValidatePath, }: { defaultAgent: ElectricEntityType | null otherAgents: Array @@ -188,33 +223,32 @@ function Picker({ onStartDefault: (text: string, args: Record) => void spawnReady: boolean error: string | null - projects: Array<{ id: string; name: string }> - activeProjectId: string | null + projects: Array + activeProject: Project | null onChangeProject: (id: string | null) => void - onCreateProject: (name: string) => { id: string } + onCreateProject: (name: string, path: string) => Promise + onValidatePath: ( + path: string + ) => Promise<{ valid: boolean; resolved: string }> }): React.ReactElement { const hasAnyAgent = defaultAgent !== null || otherAgents.length > 0 + const verb = useRotatingVerb() return ( - +
- - Start a new session + + {verb} - - {defaultAgent - ? `Type a message to start a new ${defaultAgent.name} chat, or pick another agent below.` - : `Pick the kind of agent you want to spawn.`} - +
- - {error &&
{error}
} {defaultAgent && ( @@ -263,6 +297,191 @@ function Picker({ ) } +function ProjectPicker({ + projects, + activeProject, + onChangeProject, + onCreateProject, + onValidatePath, +}: { + projects: Array + activeProject: Project | null + onChangeProject: (id: string | null) => void + onCreateProject: (name: string, path: string) => Promise + onValidatePath: ( + path: string + ) => Promise<{ valid: boolean; resolved: string }> +}): React.ReactElement { + const [open, setOpen] = useState(false) + const [creating, setCreating] = useState(false) + const [newName, setNewName] = useState(``) + const [newPath, setNewPath] = useState(``) + const [pathError, setPathError] = useState(null) + const [submitting, setSubmitting] = useState(false) + const nameRef = useRef(null) + + const resetForm = useCallback(() => { + setCreating(false) + setNewName(``) + setNewPath(``) + setPathError(null) + }, []) + + const handleCreate = useCallback(async () => { + const trimmedName = newName.trim() + const trimmedPath = newPath.trim() + if (!trimmedName || !trimmedPath) return + + setSubmitting(true) + setPathError(null) + try { + const validation = await onValidatePath(trimmedPath) + if (!validation.valid) { + setPathError(`Not a valid directory`) + return + } + const project = await onCreateProject(trimmedName, trimmedPath) + onChangeProject(project.id) + resetForm() + setOpen(false) + } catch (err) { + setPathError(err instanceof Error ? err.message : String(err)) + } finally { + setSubmitting(false) + } + }, [ + newName, + newPath, + onValidatePath, + onCreateProject, + onChangeProject, + resetForm, + ]) + + return ( + { + setOpen(nextOpen) + if (!nextOpen) resetForm() + }} + > + + {activeProject?.name ?? `Select a project`} + + + } + /> + +
Select your project
+ + + + {projects.map((p) => ( + + ))} + + {!creating ? ( + + ) : ( +
{ + e.preventDefault() + void handleCreate() + }} + > + setNewName(e.target.value)} + placeholder="Project name" + className={styles.projectCreateInput} + onKeyDown={(e) => { + if (e.key === `Escape`) resetForm() + }} + /> +
+ { + setNewPath(e.target.value) + setPathError(null) + }} + placeholder="/path/to/project" + className={styles.projectPathInput} + onKeyDown={(e) => { + if (e.key === `Escape`) resetForm() + }} + /> + +
+ {pathError && ( + {pathError} + )} +
+ )} +
+
+ ) +} + function SelectedAgentForm({ entityType, onCancel, @@ -309,12 +528,6 @@ function SelectedAgentForm({ ) } -/** - * Walk the agent's `creation_schema` and pull out the keys we know how - * to render inline as compact controls (enums and booleans). Other - * fields fall through to schema defaults; if they're required without - * a default, the user can switch to the full form via "Other agents". - */ function inlineSchemaProperties( schema: unknown ): Array<{ key: string; prop: SchemaProperty }> { @@ -322,6 +535,7 @@ function inlineSchemaProperties( const out: Array<{ key: string; prop: SchemaProperty }> = [] for (const [key, raw] of Object.entries(schema.properties)) { const prop = raw as SchemaProperty + if (key === `workingDirectory`) continue if (prop.enum && prop.enum.length > 0) { out.push({ key, prop }) } else if (prop.type === `boolean`) { @@ -344,18 +558,13 @@ function DefaultAgentComposer({ const [submitting, setSubmitting] = useState(false) const textareaRef = useRef(null) - // Auto-grow the textarea up to the CSS `max-height` cap as the - // user types (matches the chat composer in `MessageInput.tsx`). - // Reset to `auto` first so `scrollHeight` reports the natural - // content height, then assign that back as the inline height; the - // CSS bounds clamp it. Layout effect ensures the resize lands - // before paint so there's no one-frame flicker. useLayoutEffect(() => { const el = textareaRef.current if (!el) return el.style.height = `auto` el.style.height = `${el.scrollHeight}px` }, [value]) + const inlineProps = useMemo( () => inlineSchemaProperties(agent.creation_schema), [agent.creation_schema] @@ -378,8 +587,6 @@ function DefaultAgentComposer({ const trimmed = value.trim() if (!trimmed || disabled || submitting) return setSubmitting(true) - // Strip undefined/empty values so the server can fall back to schema - // defaults instead of receiving an explicit empty/null. const cleaned: Record = {} for (const [k, v] of Object.entries(args)) { if (v !== undefined && v !== ``) cleaned[k] = v @@ -462,13 +669,6 @@ function DefaultAgentComposer({ ) } -/** - * Tiny dropdown rendered as a borderless pill so it sits cleanly - * in the composer footer without competing visually with the textarea. - * Backed by the Base UI `Select` so we get a custom popover with proper - * keyboard semantics (instead of the OS-native picker, which doesn't - * blend with the rest of the surface). - */ function PillSelect({ label, value, @@ -531,92 +731,3 @@ function PillToggle({ ) } - -function ProjectPicker({ - projects, - activeProjectId, - onChangeProject, - onCreateProject, -}: { - projects: Array<{ id: string; name: string }> - activeProjectId: string | null - onChangeProject: (id: string | null) => void - onCreateProject: (name: string) => { id: string } -}): React.ReactElement { - const [creating, setCreating] = useState(false) - const [newName, setNewName] = useState(``) - const inputRef = useRef(null) - - const handleCreate = useCallback(() => { - const trimmed = newName.trim() - if (!trimmed) return - const project = onCreateProject(trimmed) - onChangeProject(project.id) - setNewName(``) - setCreating(false) - }, [newName, onCreateProject, onChangeProject]) - - return ( -
- - Project - -
- - value={activeProjectId ?? `__none__`} - onValueChange={(v) => { - if (v === `__new__`) { - setCreating(true) - setTimeout(() => inputRef.current?.focus(), 0) - } else { - onChangeProject(v === `__none__` ? null : v) - } - }} - > - - - No project - {projects.map((p) => ( - - {p.name} - - ))} - + New project… - - - - {creating && ( -
{ - e.preventDefault() - handleCreate() - }} - > - setNewName(e.target.value)} - placeholder="Project name" - className={styles.projectCreateInput} - onKeyDown={(e) => { - if (e.key === `Escape`) { - setCreating(false) - setNewName(``) - } - }} - /> - -
- )} -
-
- ) -} diff --git a/packages/agents-server-ui/src/components/Sidebar.module.css b/packages/agents-server-ui/src/components/Sidebar.module.css index 79790d8e8c..11dc11872c 100644 --- a/packages/agents-server-ui/src/components/Sidebar.module.css +++ b/packages/agents-server-ui/src/components/Sidebar.module.css @@ -125,17 +125,19 @@ .projectHeader { all: unset; + box-sizing: border-box; display: flex; align-items: center; gap: 5px; width: 100%; height: var(--ds-row-height-md); - padding: 14px 4px 4px 8px; + padding: 14px 8px 4px 8px; cursor: pointer; color: var(--ds-text-2); font-size: 10px; + font-weight: 600; text-transform: uppercase; - letter-spacing: 0.06em; + letter-spacing: 0.08em; transition: color 0.1s ease; } .projectHeader:hover { @@ -164,8 +166,9 @@ .sectionLabel { display: block; text-transform: uppercase; - letter-spacing: 0.06em; + letter-spacing: 0.07em; font-size: 10px; + font-weight: 500; padding: 14px 4px 4px 8px; color: var(--ds-text-3); } diff --git a/packages/agents-server-ui/src/components/Sidebar.tsx b/packages/agents-server-ui/src/components/Sidebar.tsx index 6aae47ff30..24fdaa49ea 100644 --- a/packages/agents-server-ui/src/components/Sidebar.tsx +++ b/packages/agents-server-ui/src/components/Sidebar.tsx @@ -45,12 +45,10 @@ function useSidebarWidth(): readonly [number, (w: number) => void] { export function Sidebar({ selectedEntityUrl, - onSelectEntity, pinnedUrls, onTogglePin, }: { selectedEntityUrl: string | null - onSelectEntity: (url: string) => void pinnedUrls: Array onTogglePin: (url: string) => void }): React.ReactElement { @@ -142,7 +140,6 @@ export function Sidebar({ const treeProps = { childrenByParent, selectedEntityUrl, - onSelectEntity, pinnedUrls, onTogglePin, hoverHandle, diff --git a/packages/agents-server-ui/src/components/SidebarFooter.module.css b/packages/agents-server-ui/src/components/SidebarFooter.module.css index 3eb15c55df..d77fa1c968 100644 --- a/packages/agents-server-ui/src/components/SidebarFooter.module.css +++ b/packages/agents-server-ui/src/components/SidebarFooter.module.css @@ -7,6 +7,6 @@ align-items: center; gap: 4px; padding: 6px 11px; - border-top: 1px solid var(--ds-divider); + border-top: 1px solid var(--ds-gray-a3); flex-shrink: 0; } diff --git a/packages/agents-server-ui/src/components/SidebarRow.module.css b/packages/agents-server-ui/src/components/SidebarRow.module.css index 163cb67ca8..5c5a827b16 100644 --- a/packages/agents-server-ui/src/components/SidebarRow.module.css +++ b/packages/agents-server-ui/src/components/SidebarRow.module.css @@ -29,6 +29,7 @@ cursor: pointer; user-select: none; color: var(--ds-text-1); + text-decoration: none; background: transparent; transition: background 0.08s ease; } @@ -36,7 +37,7 @@ background: var(--ds-gray-a3); } .selected { - background: var(--ds-accent-a3); + background: var(--ds-accent-a2); } .selected:hover { background: var(--ds-accent-a3); @@ -123,8 +124,8 @@ .type { flex-shrink: 0; padding-right: 5px; - font-size: var(--ds-text-sm); - color: var(--ds-text-3); + font-size: var(--ds-text-xs); + color: var(--ds-text-4, var(--ds-text-3)); text-transform: lowercase; line-height: 1; /* Animate the mask in/out as the row is hovered. */ @@ -142,7 +143,7 @@ padding-right: 0; } .row:hover .type { - color: var(--ds-text-2); + color: var(--ds-text-3); } /* On hover, fade the right edge of the type+count text so the chevron icon (positioned absolutely on top of the same area) has diff --git a/packages/agents-server-ui/src/components/SidebarRow.tsx b/packages/agents-server-ui/src/components/SidebarRow.tsx index c674880347..3da350a7e0 100644 --- a/packages/agents-server-ui/src/components/SidebarRow.tsx +++ b/packages/agents-server-ui/src/components/SidebarRow.tsx @@ -1,5 +1,6 @@ import { memo } from 'react' import { ChevronDown, ChevronRight, Pin } from 'lucide-react' +import { Link } from '@tanstack/react-router' import { StatusDot } from './StatusDot' import { HoverCard, Text } from '../ui' import { getEntityDisplayTitle } from '../lib/entityDisplay' @@ -32,7 +33,6 @@ type HoverCardHandle = ReturnType< type SidebarRowProps = { entity: ElectricEntity selected: boolean - onSelect: () => void depth?: number /** Number of immediate children. 0 means no expand affordance. */ childCount?: number @@ -78,7 +78,6 @@ type SidebarRowProps = { export const SidebarRow = memo(function SidebarRow({ entity, selected, - onSelect, depth = 0, childCount = 0, expanded = false, @@ -106,24 +105,20 @@ export const SidebarRow = memo(function SidebarRow({ childCount, } + const splat = entity.url.replace(/^\//, ``) + return ( { - if (e.key === `Enter` || e.key === ` `) { - e.preventDefault() - onSelect() - } - }} style={{ paddingLeft: BASE_PADDING_LEFT + depth * INDENT_PX }} title={title} + preload="intent" > @@ -194,7 +189,7 @@ export const SidebarRow = memo(function SidebarRow({ ) ) : null} - + } /> ) diff --git a/packages/agents-server-ui/src/components/SidebarTree.tsx b/packages/agents-server-ui/src/components/SidebarTree.tsx index 606233f2e0..48a94db985 100644 --- a/packages/agents-server-ui/src/components/SidebarTree.tsx +++ b/packages/agents-server-ui/src/components/SidebarTree.tsx @@ -10,7 +10,6 @@ type SidebarTreeProps = { entity: ElectricEntity childrenByParent: Map> selectedEntityUrl: string | null - onSelectEntity: (url: string) => void pinnedUrls: ReadonlyArray onTogglePin: (url: string) => void depth?: number @@ -64,7 +63,6 @@ export const SidebarTree = memo(function SidebarTree({ entity, childrenByParent, selectedEntityUrl, - onSelectEntity, pinnedUrls, onTogglePin, depth = 0, @@ -89,7 +87,6 @@ export const SidebarTree = memo(function SidebarTree({ onSelectEntity(entity.url)} depth={depth} childCount={children.length} expanded={expanded} @@ -106,7 +103,6 @@ export const SidebarTree = memo(function SidebarTree({ entity={child} childrenByParent={childrenByParent} selectedEntityUrl={selectedEntityUrl} - onSelectEntity={onSelectEntity} pinnedUrls={pinnedUrls} onTogglePin={onTogglePin} depth={depth + 1} diff --git a/packages/agents-server-ui/src/components/ToolCallView.tsx b/packages/agents-server-ui/src/components/ToolCallView.tsx index ecad059f25..d19b5b1e80 100644 --- a/packages/agents-server-ui/src/components/ToolCallView.tsx +++ b/packages/agents-server-ui/src/components/ToolCallView.tsx @@ -103,16 +103,12 @@ function ToolBody({ item }: { item: ToolCallItem }): React.ReactElement { const timedOut = r.details.timedOut as boolean | undefined return ( - - Command - + Command
{args.command as string}
{r.text && ( <> - - Output - + Output {exitCode !== undefined && exitCode !== 0 && ( exit {exitCode} @@ -134,9 +130,7 @@ function ToolBody({ item }: { item: ToolCallItem }): React.ReactElement { case `read`: return ( - - Content - + Content
             {r.text ? truncate(r.text, 2000) : `(empty)`}
           
@@ -183,9 +177,7 @@ function ToolBody({ item }: { item: ToolCallItem }): React.ReactElement { {typeof args.content === `string` && ( <> - - Content - + Content
                 {truncate(args.content, 1000)}
               
@@ -202,17 +194,13 @@ function ToolBody({ item }: { item: ToolCallItem }): React.ReactElement { default: return ( - - Input - + Input
             {JSON.stringify(args, null, 2)}
           
{r.text && ( <> - - Output - + Output
{r.text}
)} diff --git a/packages/agents-server-ui/src/components/UserMessage.module.css b/packages/agents-server-ui/src/components/UserMessage.module.css index 198a5bb9a5..4a9e86f7ac 100644 --- a/packages/agents-server-ui/src/components/UserMessage.module.css +++ b/packages/agents-server-ui/src/components/UserMessage.module.css @@ -14,7 +14,7 @@ user's "voice" turn. */ .bubble { background: var(--ds-input-bg); - border: 1px solid var(--ds-gray-a4); + border: 1px solid var(--ds-gray-a3); border-radius: 12px; } diff --git a/packages/agents-server-ui/src/components/toolBlock.module.css b/packages/agents-server-ui/src/components/toolBlock.module.css index 1bd85679e4..cc864d12d6 100644 --- a/packages/agents-server-ui/src/components/toolBlock.module.css +++ b/packages/agents-server-ui/src/components/toolBlock.module.css @@ -19,9 +19,10 @@ .card { border: 1px solid var(--ds-gray-a3); - border-radius: var(--ds-radius-3); + border-radius: var(--ds-radius-4); overflow: hidden; - background: var(--ds-gray-a1); + background: var(--ds-surface); + box-shadow: 0 1px 2px rgba(15, 15, 30, 0.04); } /* The header is metadata about the tool call, not part of the @@ -34,11 +35,12 @@ display: flex; align-items: center; gap: 8px; - padding: 6px 10px; + padding: 7px 10px; font-size: 12px; line-height: 1.45; - font-family: var(--ds-font-body); + font-family: var(--ds-font-mono); color: var(--ds-text-1); + background: var(--ds-gray-a1); } /* Strip `