From 839ace5678cda7ac6e386c68a8223aceeb0192d1 Mon Sep 17 00:00:00 2001 From: kasiazjc Date: Thu, 2 Jul 2026 13:49:15 +0000 Subject: [PATCH 01/20] feat(types): add default_agentic_tool user preference Lets a user mark a primary agentic tool for quick-start session creation. Lives in UserPreferences (already a flexible JSON blob), so no DB migration is needed. --- packages/core/src/types/user.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/core/src/types/user.ts b/packages/core/src/types/user.ts index c84c595f2..6ab8f8dde 100644 --- a/packages/core/src/types/user.ts +++ b/packages/core/src/types/user.ts @@ -1,4 +1,9 @@ -import type { CodexApprovalPolicy, CodexNetworkAccess, CodexSandboxMode } from './agentic-tool'; +import type { + AgenticToolName, + CodexApprovalPolicy, + CodexNetworkAccess, + CodexSandboxMode, +} from './agentic-tool'; import type { UserID } from './id'; import type { EffortLevel, PermissionMode } from './session'; @@ -385,6 +390,15 @@ export interface UserPreferences { mainBoardId?: string; /** Whether to render Slack-synced avatar_url when available. Undefined defaults to true. */ use_slack_avatar?: boolean; + /** + * Primary agentic tool for quick-start session creation ("Add session" + * skips the picker and uses this). Set explicitly in Settings, or + * implicitly whenever the user picks a tool from the quick-start empty + * state / the in-session "Switch tool" affordance — either counts as + * "latest used" going forward. Unset means quick-start can't resolve a + * tool on its own and falls back to the tile picker. + */ + default_agentic_tool?: AgenticToolName; // Future preferences can be added here [key: string]: unknown; } From 05c39cf645c56561ed14071f9786b2fb4464cec6 Mon Sep 17 00:00:00 2001 From: kasiazjc Date: Thu, 2 Jul 2026 13:54:41 +0000 Subject: [PATCH 02/20] feat(settings): expose default agentic tool picker in general tab Lets a user set/clear their primary tool for quick-start session creation, backed by the new preferences.default_agentic_tool field. --- .../SettingsModal/UserSettingsModal.tsx | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/apps/agor-ui/src/components/SettingsModal/UserSettingsModal.tsx b/apps/agor-ui/src/components/SettingsModal/UserSettingsModal.tsx index b1911f8fe..fa5e46fde 100644 --- a/apps/agor-ui/src/components/SettingsModal/UserSettingsModal.tsx +++ b/apps/agor-ui/src/components/SettingsModal/UserSettingsModal.tsx @@ -49,9 +49,11 @@ import { getClearedFormValues, getFormValuesFromConfig, } from '../AgenticToolConfigForm'; +import { AVAILABLE_AGENTS } from '../AgentSelectionGrid/availableAgents'; import { ApiKeyFields, type FieldStatus, TOOL_FIELD_CONFIGS } from '../ApiKeyFields'; import { FormEmojiPickerInput } from '../EmojiPickerInput'; import { EnvVarEditor } from '../EnvVarEditor'; +import { ToolIcon } from '../ToolIcon'; import { AudioSettingsTab } from './AudioSettingsTab'; import { syncGroupsForUser } from './groupMembershipSync'; import { PersonalApiKeysTab } from './PersonalApiKeysTab'; @@ -192,6 +194,7 @@ export const UserSettingsModal: React.FC = ({ groupIds: [], eventStreamEnabled: userData.preferences?.eventStream?.enabled ?? true, useSlackAvatar: userData.preferences?.use_slack_avatar !== false, + defaultAgenticTool: userData.preferences?.default_agentic_tool, must_change_password: userData.must_change_password ?? false, }); }, @@ -394,6 +397,11 @@ export const UserSettingsModal: React.FC = ({ } else { delete nextPreferences.use_slack_avatar; } + if (values.defaultAgenticTool) { + nextPreferences.default_agentic_tool = values.defaultAgenticTool; + } else { + delete nextPreferences.default_agentic_tool; + } const updates: UpdateUserInput = { email: values.email, @@ -793,6 +801,26 @@ export const UserSettingsModal: React.FC = ({ + + setTitleDraft(e.target.value)} + onBlur={saveTitle} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + saveTitle(); + } else if (e.key === 'Escape') { + e.preventDefault(); + setEditingTitle(false); + } + }} + placeholder="Untitled session" + variant="borderless" + style={{ fontSize: 18, fontWeight: 600, padding: 0 }} + /> + ) : ( + { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + startEditingTitle(); + } + }} + title="Click to rename" + style={{ + fontSize: 18, + cursor: 'text', + ...getSessionTitleStyles(2), + ...(session.title || session.description ? {} : { fontStyle: 'italic' }), + }} + type={session.title || session.description ? undefined : 'secondary'} + > + {session.title || session.description + ? getSessionDisplayTitle(session, { includeAgentFallback: false }) + : 'Untitled session'} + + )} {session.created_by && (
From cc0787b86bb682461ec9d643c7696cbbe499c9aa Mon Sep 17 00:00:00 2001 From: kasiazjc Date: Thu, 2 Jul 2026 14:13:45 +0000 Subject: [PATCH 07/20] feat(session-panel): add secondary "Switch tool" affordance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reuses the same chooseAgenticTool action the quick-start empty state uses, with the current session passed as replacingSessionId. Only offered via the "..." menu when the session has zero tasks (never prompted yet) — tool is a per-session SDK choice baked into every task, so it can't safely change once a conversation exists. --- .../components/SessionPanel/SessionPanel.tsx | 68 ++++++++++++++++++- 1 file changed, 66 insertions(+), 2 deletions(-) diff --git a/apps/agor-ui/src/components/SessionPanel/SessionPanel.tsx b/apps/agor-ui/src/components/SessionPanel/SessionPanel.tsx index ef43c3b70..c8d08f2d9 100644 --- a/apps/agor-ui/src/components/SessionPanel/SessionPanel.tsx +++ b/apps/agor-ui/src/components/SessionPanel/SessionPanel.tsx @@ -1,4 +1,5 @@ import type { + AgenticToolName, AgorClient, Branch, CodexApprovalPolicy, @@ -25,6 +26,7 @@ import { DownOutlined, EllipsisOutlined, InboxOutlined, + RobotOutlined, SearchOutlined, SettingOutlined, UpOutlined, @@ -37,6 +39,7 @@ import { Button, Dropdown, Input, + Modal, Space, Tooltip, Typography, @@ -60,6 +63,7 @@ import { getContextWindowGradient } from '../../utils/contextWindow'; import { mcpServerNeedsAuth } from '../../utils/mcpAuth'; import { useThemedMessage } from '../../utils/message'; import { getSessionDisplayTitle, getSessionTitleStyles } from '../../utils/sessionTitle'; +import { AgentSelectionGrid } from '../AgentSelectionGrid/AgentSelectionGrid'; import { AutocompleteTextarea } from '../AutocompleteTextarea'; import { FileUpload } from '../FileUpload'; import { ForkSpawnModal } from '../ForkSpawnModal/ForkSpawnModal'; @@ -310,8 +314,16 @@ const SessionPanel: React.FC = ({ const userAuthenticatedMcpServerIds = useAgorStore(selectUserAuthenticatedMcpServerIds); // Get actions from context - const { onSendPrompt, onFork, onBtwFork, onOpenSettings, onUpdateSession, onOpenTerminal } = - useAppActions(); + const { + onSendPrompt, + onFork, + onBtwFork, + onOpenSettings, + onUpdateSession, + onOpenTerminal, + onChooseAgenticTool, + availableAgents, + } = useAppActions(); const { archiveSession } = useSessionActions(client); @@ -338,6 +350,16 @@ const SessionPanel: React.FC = ({ if (editingTitle) titleInputRef.current?.focus(); }, [editingTitle]); + // "Switch tool" — same underlying chooseAgenticTool action the quick-start + // empty-state tiles use, just with `replacingSessionId` set. Only offered + // on a session with zero tasks (never prompted yet): tool is a per-session + // SDK choice baked into every task, so there's no safe way to change it + // once a conversation exists — hide the affordance entirely rather than + // let it fail or silently drop history. (See `canSwitchTool` / + // `handleSwitchTool` below the early-return, once `session` is narrowed.) + const [switchToolOpen, setSwitchToolOpen] = React.useState(false); + const [switchingTool, setSwitchingTool] = React.useState(false); + // Tool capabilities — drives which buttons are shown const toolCaps = session?.agentic_tool ? AGENTIC_TOOL_CAPABILITIES[session.agentic_tool] @@ -728,6 +750,17 @@ const SessionPanel: React.FC = ({ }; const hasBranchActions = !!branch; + const canSwitchTool = !!branch && !!onChooseAgenticTool && session.tasks.length === 0; + const handleSwitchTool = async (tool: string) => { + if (!branch || !onChooseAgenticTool || switchingTool) return; + setSwitchingTool(true); + try { + await onChooseAgenticTool(branch.branch_id, tool as AgenticToolName, session.session_id); + setSwitchToolOpen(false); + } finally { + setSwitchingTool(false); + } + }; const moreMenuItems: MenuProps['items'] = [ ...(branch ? [ @@ -760,6 +793,16 @@ const SessionPanel: React.FC = ({ }, ] : []), + ...(canSwitchTool + ? [ + { + key: 'switch-tool', + icon: , + label: 'Switch tool…', + onClick: () => setSwitchToolOpen(true), + }, + ] + : []), { key: 'archive', icon: , @@ -1490,6 +1533,27 @@ const SessionPanel: React.FC = ({ client={client} userById={userById} /> + + {/* Switch tool — same tile picker as the quick-start empty state, + just replacing this (never-prompted) session instead of creating + the first one. Only reachable via moreMenuItems when canSwitchTool. */} + setSwitchToolOpen(false)} + footer={null} + > + + Choose a different tool for this session. Since nothing has been sent yet, this replaces + the session in place. + + +
); From bdd749e892214ccb3590d2e6eadc9b5e43afbd20 Mon Sep 17 00:00:00 2001 From: kasiazjc Date: Thu, 2 Jul 2026 14:17:24 +0000 Subject: [PATCH 08/20] feat(sessions): auto-title a session from its first prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cheap, synchronous heuristic (collapse whitespace, cut at ~60 chars on a word boundary) with no LLM call — tool-agnostic since it only reads Task.full_prompt, already stored regardless of which agentic tool ran it. Wired into the existing task-completion hook in TasksService.patch: fires once, when a session's first task completes successfully with still no explicit title. A user-set title (including one typed while the task was still running) always wins, since by the time the hook runs session.title would already be non-empty. --- .../src/services/tasks.auto-title.test.ts | 154 ++++++++++++++++++ apps/agor-daemon/src/services/tasks.ts | 16 ++ .../sessions/derive-title-from-prompt.test.ts | 39 +++++ .../src/sessions/derive-title-from-prompt.ts | 25 +++ packages/core/src/sessions/index.ts | 1 + 5 files changed, 235 insertions(+) create mode 100644 apps/agor-daemon/src/services/tasks.auto-title.test.ts create mode 100644 packages/core/src/sessions/derive-title-from-prompt.test.ts create mode 100644 packages/core/src/sessions/derive-title-from-prompt.ts diff --git a/apps/agor-daemon/src/services/tasks.auto-title.test.ts b/apps/agor-daemon/src/services/tasks.auto-title.test.ts new file mode 100644 index 000000000..999b6691b --- /dev/null +++ b/apps/agor-daemon/src/services/tasks.auto-title.test.ts @@ -0,0 +1,154 @@ +import { type Session, type Task, TaskStatus } from '@agor/core/types'; +import { describe, expect, it, vi } from 'vitest'; +import { TasksService } from './tasks'; + +const sessionId = '018f0000-0000-7000-8000-000000000101'; +const taskId = '018f0000-0000-7000-8000-000000000201'; +const userId = '018f0000-0000-7000-8000-000000000401'; + +function makeTask(overrides: Partial = {}): Task { + return { + task_id: taskId, + session_id: sessionId, + created_by: userId, + full_prompt: 'Add a JWT-based authentication system with refresh token rotation', + status: TaskStatus.RUNNING, + message_range: { + start_index: 0, + end_index: 2, + start_timestamp: '2026-01-01T00:00:00.000Z', + }, + tool_use_count: 1, + git_state: { ref_at_start: 'main', sha_at_start: 'abc123' }, + created_at: '2026-01-01T00:00:00.000Z', + ...overrides, + } as Task; +} + +function makeSession(overrides: Partial = {}): Session { + return { + session_id: sessionId, + branch_id: undefined, + created_by: userId, + agentic_tool: 'claude-code', + status: 'running', + tasks: [taskId], + ready_for_prompt: false, + archived: false, + genealogy: { children: [] }, + git_state: {}, + contextFiles: [], + created_at: '2026-01-01T00:00:00.000Z', + updated_at: '2026-01-01T00:00:00.000Z', + ...overrides, + } as Session; +} + +function makeService(options: { task?: Partial; session?: Partial } = {}) { + const initialTask = makeTask(options.task); + const tasksById = new Map([[initialTask.task_id, initialTask]]); + const session = makeSession(options.session); + + const repository = { + findById: vi.fn(async (id: string) => tasksById.get(id) ?? null), + update: vi.fn(async (id: string, updates: Partial) => { + const current = tasksById.get(id) ?? makeTask({ task_id: id as Task['task_id'] }); + const updated = { ...current, ...updates } as Task; + tasksById.set(id, updated); + return updated; + }), + create: vi.fn(), + findAll: vi.fn(async () => [...tasksById.values()]), + delete: vi.fn(), + }; + + const sessionsPatch = vi.fn(async (id: string, updates: Partial) => { + Object.assign(session, updates); + return { ...session }; + }); + const triggerQueueProcessing = vi.fn(async () => undefined); + + const service = Object.create(TasksService.prototype) as TasksService & { + repository: typeof repository; + taskRepo: typeof repository & { createPending: ReturnType }; + id: string; + emit: ReturnType; + app: { service: ReturnType }; + completionCallbackDispatches: Map>; + }; + service.repository = repository; + service.taskRepo = { ...repository, createPending: vi.fn() }; + service.id = 'task_id'; + service.emit = vi.fn(); + service.completionCallbackDispatches = new Map(); + service.app = { + service: vi.fn((name: string) => { + if (name === 'sessions') { + return { get: vi.fn(async () => session), patch: sessionsPatch, triggerQueueProcessing }; + } + if (name === 'messages') return { find: vi.fn(async () => []) }; + if (name === 'branches') return { get: vi.fn() }; + throw new Error(`unexpected service ${name}`); + }), + }; + + return { service, sessionsPatch, session }; +} + +describe('TasksService auto-title', () => { + it('derives a title from the first prompt when the first task completes with no explicit title', async () => { + const { service, sessionsPatch } = makeService({ session: { title: undefined } }); + + await service.patch(taskId, { + status: TaskStatus.COMPLETED, + completed_at: '2026-01-01T00:00:05.000Z', + }); + + expect(sessionsPatch).toHaveBeenCalledWith( + sessionId, + expect.objectContaining({ + title: 'Add a JWT-based authentication system with refresh token…', + }), + undefined + ); + }); + + it('does not overwrite an explicit title', async () => { + const { service, sessionsPatch } = makeService({ session: { title: 'My session' } }); + + await service.patch(taskId, { + status: TaskStatus.COMPLETED, + completed_at: '2026-01-01T00:00:05.000Z', + }); + + const [, updates] = sessionsPatch.mock.calls[0]; + expect(updates).not.toHaveProperty('title'); + }); + + it('does not set a title for a later task in the same session', async () => { + const earlierTaskId = '018f0000-0000-7000-8000-000000000202'; + const { service, sessionsPatch } = makeService({ + session: { title: undefined, tasks: [earlierTaskId, taskId] }, + }); + + await service.patch(taskId, { + status: TaskStatus.COMPLETED, + completed_at: '2026-01-01T00:00:05.000Z', + }); + + const [, updates] = sessionsPatch.mock.calls[0]; + expect(updates).not.toHaveProperty('title'); + }); + + it('does not set a title when the task fails', async () => { + const { service, sessionsPatch } = makeService({ session: { title: undefined } }); + + await service.patch(taskId, { + status: TaskStatus.FAILED, + completed_at: '2026-01-01T00:00:05.000Z', + }); + + const [, updates] = sessionsPatch.mock.calls[0]; + expect(updates).not.toHaveProperty('title'); + }); +}); diff --git a/apps/agor-daemon/src/services/tasks.ts b/apps/agor-daemon/src/services/tasks.ts index 12c54f15b..d5557489e 100644 --- a/apps/agor-daemon/src/services/tasks.ts +++ b/apps/agor-daemon/src/services/tasks.ts @@ -18,6 +18,7 @@ import { type TenantScopeAwareDatabase, } from '@agor/core/db'; import type { Application } from '@agor/core/feathers'; +import { deriveTitleFromPrompt } from '@agor/core/sessions'; import type { ContentBlock, Paginated, @@ -609,12 +610,27 @@ export class TasksService extends DrizzleService, TaskParams `⏭️ [TasksService] Skipping session terminal-state update for task ${shortId(task.task_id)} (${data.status}) due to internal patch params` ); } else { + // Auto-title: cheap heuristic, no LLM call — see + // `deriveTitleFromPrompt`. Only fires once, on the session's + // first task completing successfully while it still has no + // explicit title (a user-set title, including one typed while + // this task was still running, always wins — this is a no-op + // by the time we get here). + const autoTitle = + data.status === TaskStatus.COMPLETED && + !session.title && + session.tasks?.length === 1 && + task.full_prompt + ? deriveTitleFromPrompt(task.full_prompt) + : ''; + await this.app.service('sessions').patch( task.session_id, { status: data.status === TaskStatus.FAILED ? SessionStatus.FAILED : SessionStatus.IDLE, ready_for_prompt: true, + ...(autoTitle ? { title: autoTitle } : {}), }, params ); diff --git a/packages/core/src/sessions/derive-title-from-prompt.test.ts b/packages/core/src/sessions/derive-title-from-prompt.test.ts new file mode 100644 index 000000000..791991347 --- /dev/null +++ b/packages/core/src/sessions/derive-title-from-prompt.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { deriveTitleFromPrompt } from './derive-title-from-prompt.js'; + +describe('deriveTitleFromPrompt', () => { + it('returns short prompts unchanged', () => { + expect(deriveTitleFromPrompt('Add authentication to the login flow')).toBe( + 'Add authentication to the login flow' + ); + }); + + it('collapses internal whitespace and newlines', () => { + expect(deriveTitleFromPrompt('Fix the\n\nbug in checkout')).toBe('Fix the bug in checkout'); + }); + + it('trims leading/trailing whitespace', () => { + expect(deriveTitleFromPrompt(' hello world ')).toBe('hello world'); + }); + + it('truncates long prompts at a word boundary with an ellipsis', () => { + const prompt = + 'Implement a full JWT-based authentication system with secure password storage and refresh token rotation'; + const title = deriveTitleFromPrompt(prompt); + expect(title.length).toBeLessThanOrEqual(61); + expect(title.endsWith('…')).toBe(true); + expect(title).not.toMatch(/\s…$/); + }); + + it('hard-cuts when there is no reasonable word boundary', () => { + const prompt = `${'a'.repeat(100)} b`; + const title = deriveTitleFromPrompt(prompt); + expect(title.length).toBeLessThanOrEqual(61); + expect(title.endsWith('…')).toBe(true); + }); + + it('returns an empty string for blank input', () => { + expect(deriveTitleFromPrompt(' ')).toBe(''); + expect(deriveTitleFromPrompt('')).toBe(''); + }); +}); diff --git a/packages/core/src/sessions/derive-title-from-prompt.ts b/packages/core/src/sessions/derive-title-from-prompt.ts new file mode 100644 index 000000000..b011b8a55 --- /dev/null +++ b/packages/core/src/sessions/derive-title-from-prompt.ts @@ -0,0 +1,25 @@ +/** + * Auto-title heuristic — derives a short session title from a user's first + * prompt, with no LLM call. + * + * Used by the daemon's task-completion hook ({@link TasksService.patch}) + * when a session's first task finishes and the session still has no + * explicit title: cheap, synchronous, and tool-agnostic (works the same for + * every agentic tool, since it only reads the prompt text already stored on + * the task — see `Task.full_prompt`). + */ + +const MAX_TITLE_LENGTH = 60; + +export function deriveTitleFromPrompt(prompt: string): string { + const collapsed = prompt.replace(/\s+/g, ' ').trim(); + if (!collapsed) return ''; + if (collapsed.length <= MAX_TITLE_LENGTH) return collapsed; + + const truncated = collapsed.slice(0, MAX_TITLE_LENGTH); + const lastSpace = truncated.lastIndexOf(' '); + // Only break on a word boundary if it doesn't throw away most of the + // budget (e.g. one very long leading word) — otherwise just hard-cut. + const cut = lastSpace > MAX_TITLE_LENGTH * 0.4 ? truncated.slice(0, lastSpace) : truncated; + return `${cut}…`; +} diff --git a/packages/core/src/sessions/index.ts b/packages/core/src/sessions/index.ts index f6feda10e..a8801d785 100644 --- a/packages/core/src/sessions/index.ts +++ b/packages/core/src/sessions/index.ts @@ -1,3 +1,4 @@ +export * from './derive-title-from-prompt.js'; export * from './resolve-child-session-config.js'; export * from './resolve-permission-config.js'; export * from './resolve-session-defaults.js'; From 7fd8bd27a0cab3520a071b9a762fb7e032b8c512 Mon Sep 17 00:00:00 2001 From: kasiazjc Date: Fri, 3 Jul 2026 06:48:02 +0000 Subject: [PATCH 09/20] test(session-panel): cover PendingToolChoicePanel Tiles render per available agent, picking one calls onChoose with the tool id, and the advanced-setup escape hatch only shows up when a handler is provided. --- .../PendingToolChoicePanel.test.tsx | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 apps/agor-ui/src/components/SessionPanel/PendingToolChoicePanel.test.tsx diff --git a/apps/agor-ui/src/components/SessionPanel/PendingToolChoicePanel.test.tsx b/apps/agor-ui/src/components/SessionPanel/PendingToolChoicePanel.test.tsx new file mode 100644 index 000000000..82fdd5047 --- /dev/null +++ b/apps/agor-ui/src/components/SessionPanel/PendingToolChoicePanel.test.tsx @@ -0,0 +1,91 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { App, ConfigProvider } from 'antd'; +import type React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import type { AgenticToolOption } from '../AgentSelectionGrid/AgentSelectionGrid'; +import { PendingToolChoicePanel } from './PendingToolChoicePanel'; + +const Wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( + + {children} + +); + +const agents: AgenticToolOption[] = [ + { id: 'claude-code', name: 'Claude Code', icon: '🤖', description: 'Anthropic' }, + { id: 'codex', name: 'Codex', icon: '💻', description: 'OpenAI' }, +]; + +describe('PendingToolChoicePanel', () => { + it('renders one tile per available agent and the inert composer bar', () => { + render( + + + + ); + + expect(screen.getByText('Claude Code')).toBeInTheDocument(); + expect(screen.getByText('Codex')).toBeInTheDocument(); + expect(screen.getByText('Untitled session')).toBeInTheDocument(); + + const textarea = screen.getByPlaceholderText('Pick a tool above to start typing…'); + expect(textarea).toHaveAttribute('disabled'); + expect(screen.getByText('Options').closest('button')).toHaveAttribute('disabled'); + expect(screen.getByText('Send').closest('button')).toHaveAttribute('disabled'); + }); + + it('calls onChoose with the tool id when a tile is clicked', () => { + const onChoose = vi.fn(); + render( + + + + ); + + fireEvent.click(screen.getByText('Codex')); + expect(onChoose).toHaveBeenCalledWith('codex'); + }); + + it('only shows the advanced-setup escape hatch when a handler is provided', () => { + const { rerender } = render( + + + + ); + expect(screen.queryByText(/advanced setup/i)).not.toBeInTheDocument(); + + const onAdvancedSetup = vi.fn(); + rerender( + + + + ); + fireEvent.click(screen.getByText(/advanced setup/i)); + expect(onAdvancedSetup).toHaveBeenCalled(); + }); +}); From 94c08a0731e2d79d55e7ca0cb56fa04de05fb26a Mon Sep 17 00:00:00 2001 From: kasiazjc Date: Fri, 3 Jul 2026 06:52:06 +0000 Subject: [PATCH 10/20] test(session-panel): cover inline title edit and switch-tool affordance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Also hardens canSwitchTool against a missing session.tasks array (session.tasks is typed as always-present, but defend anyway rather than let a malformed/legacy record crash the whole panel) — the existing SessionPanel test only avoided this because it doesn't wire onChooseAgenticTool, short-circuiting before the access. --- .../SessionPanel.quickstart.test.tsx | 179 ++++++++++++++++++ .../components/SessionPanel/SessionPanel.tsx | 2 +- 2 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 apps/agor-ui/src/components/SessionPanel/SessionPanel.quickstart.test.tsx diff --git a/apps/agor-ui/src/components/SessionPanel/SessionPanel.quickstart.test.tsx b/apps/agor-ui/src/components/SessionPanel/SessionPanel.quickstart.test.tsx new file mode 100644 index 000000000..6eb13c071 --- /dev/null +++ b/apps/agor-ui/src/components/SessionPanel/SessionPanel.quickstart.test.tsx @@ -0,0 +1,179 @@ +import type { Branch, Session } from '@agor-live/client'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { App as AntApp } from 'antd'; +import { describe, expect, it, vi } from 'vitest'; +import { AppActionsProvider } from '../../contexts/AppActionsContext'; +import { ConnectionProvider } from '../../contexts/ConnectionContext'; +import type { AgenticToolOption } from '../../types'; +import SessionPanel from './SessionPanel'; + +vi.mock('../AutocompleteTextarea', () => ({ + AutocompleteTextarea: () =>