diff --git a/.gitignore b/.gitignore index 588449fd..16af7d81 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ bun.lockb .archcode .specra .playwright-mcp +.codex/config.toml __test_tmp__/ __test__/ .turbo/ diff --git a/AGENTS.md b/AGENTS.md index f54ec4d1..cf6d11ca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,9 +71,11 @@ design-system/ └── prototypes/ ``` -`MASTER.md` and `pages/` are normative design specifications. -`prototypes/` contains supporting visual references, not authoritative -specifications. +`MASTER.md` and `pages/` define approved product and interaction contracts. +For a page with a current effective prototype, that prototype's actual browser +render is the visual delivery and visual acceptance authority. Source code, +tokens, DOM inspection, screenshots inferred from CSS, or prose specifications +cannot substitute for looking at the rendered prototype and rendered product. Follow this order for every UI task: @@ -82,9 +84,11 @@ Follow this order for every UI task: explicit deviations from the Master and override it only for that page. 3. Inspect the current rendered product and implementation for established product behavior, state semantics, and component interactions. -4. Inspect the relevant current HTML prototype under - `design-system/prototypes/` when one exists. Use it only as a supporting - rendered reference. +4. Open the relevant current HTML prototype under `design-system/prototypes/` + in a real browser when one exists. Use its actual rendered visual result as + the target for layout, density, typography, color, responsive behavior, and + interaction presentation; never claim visual completion from file or code + inspection alone. 5. UI/UX Pro Max is advisory, not authoritative. Use its searches as candidate evidence and filter recommendations through ArchCode's established quiet engineering workbench direction; never replace the Master with generic @@ -94,9 +98,15 @@ Follow this order for every UI task: product implementation as applicable. Resolve conflicts as follows: a page override modifies `MASTER.md` only for -that page; the current product is authoritative for existing behavior and state -mechanics; an HTML prototype never overrides `MASTER.md`, a page override, or -current product behavior. +that page; the current product is authoritative for existing runtime facts, +domain state, persistence, and behavior that an approved design decision does +not intentionally change. The current prototype is authoritative for visual +delivery, while `MASTER.md` and page overrides define the product/interaction +rules that the visual must satisfy. A prototype never invents product +capability: if it visibly implies behavior the product does not have, classify +the mismatch before implementation. Fix an obvious prototype defect in the +prototype; escalate a genuine product decision to the user. Only explicitly +approved hard-cut decisions may remove or change existing product behavior. Create or update an HTML prototype only for a new complex module, a major layout change, or an uncertain visual direction. Routine UI fixes and small @@ -111,12 +121,16 @@ that page's HTML instead of creating page-specific `.css` or `.js` files. The current references are: -- Global Home: `design-system/prototypes/dashboard.html` +- Root no-project entry: `design-system/prototypes/index.html` - Todos: `design-system/prototypes/todos.html` - Automations: `design-system/prototypes/automations.html` - Sessions: `design-system/prototypes/sessions.html` - Session detail: `design-system/prototypes/session.html` +There is no Dashboard prototype. The root entry is only the zero-project +registration state; with a current/last project, `/` resolves to that project's +All todos surface as specified by `design-system/pages/root.md`. + When browser QA needs an HTTP origin, serve the prototype root without first changing into that directory: @@ -307,7 +321,11 @@ Every descriptor declares an explicit `outputPolicy`. Registry is the sole Raw-t - `profiles.principal`, `profiles.deep`, and `profiles.fast` are all required, and unknown configuration keys fail strict validation. - Profile-default merge order is shallow: model `options` → selected `variants[variant]` → Profile `options`. A user-facing root Session override resolves independently and never inherits principal Profile options. - `providerOptions` follows the same shallow merge rule as one top-level key: later layers replace the whole `providerOptions` object rather than deep-merging nested provider settings. -- Unknown model ids, unknown variant names, and missing Profile config all fail fast with actionable errors. +- Missing required Profile config and unknown model ids are invalid. A Profile + may retain a removed Variant name for repair: Settings presents it as + attention and the runtime resolves that Profile through the selected Model's + default options until repaired. An explicit invalid Session override falls + back to its selected Profile; it never passes the unknown Variant downstream. - LLM execution is centralized in `packages/agent-core/src/llm/`. Non-LLM runtime code must not import `streamText` or `generateText` directly from `"ai"`; use `runLlmStream`, `runLlmText`, or `runLlmObject` instead. - `maxRetries` is not a configuration field. Managed calls force AI SDK `maxRetries: 0` so ArchCode owns retry/recovery, including HTTP 200 stream-body EOF/truncated-SSE failures that AI SDK retries cannot recover. - Retry constants are internal v1 implementation details. There is no global recovery retry config yet. Existing auto-compact behavior is preserved; emergency context-overflow compact automation is follow-up/out-of-scope. @@ -467,13 +485,13 @@ Project: `.archcode/runtime/memory/`, User: `~/.archcode/memory/` (user-global, ## Project Todos -Project Todos are project-owned intent, separate from Session-local `todo_write` execution checklists. Global `/` is Home; `/projects/:slug` redirects to the Project's `/projects/:slug/todos` board. Project pages share one `Todos / Automations / Sessions` toolbar, while Sessions remain independently creatable and recoverable execution workbenches. `ProjectTodoStateManager` owns strict Todo persistence, flat state updates (`idea`, `ready`, `in_progress`, `done`, `rejected`), archive state, revision checks, the canonical array order, ordered current `attachmentIds`, and narrow durable Run-now receipts. `ProjectTodoService` is the only Todo application boundary: it exposes list/create/flat-update, attachment operations, the composed `Run now` command, and root Session creation for `discussion`, `work`, and `automation`. A Todo never stores reverse Session, Plan, or Automation links. +Project Todos are project-owned intent, separate from Session-local `todo_write` execution checklists. Global `/` is only a registry-backed entry resolver: it replaces to the current/last valid project's `/projects/:slug/todos`, falls back to the first registered project in server order, or renders the sole no-project registration state. `ProjectLayout` owns the persistent Todo navigator, New Todo capture, and project-scoped Inspector; there is no aggregate Home and no `Todos / Automations / Sessions` project toolbar. `/projects/:slug` redirects to `/projects/:slug/todos`, while Runs and Schedules remain independently creatable and recoverable operational destinations inside the Todo workbench. `ProjectTodoStateManager` owns strict Todo persistence, flat state updates (`idea`, `ready`, `in_progress`, `done`, `rejected`), archive state, revision checks, the canonical array order, ordered current `attachmentIds`, and narrow durable Run-now receipts. `ProjectTodoService` is the only Todo application boundary: it exposes list/create/flat-update, attachment operations, the composed `Run now` command, and root Session creation for `discussion`, `work`, and `automation`. A Todo never stores reverse Session, Plan, or Automation links. A Todo can have any number of root Sessions with immutable `{ kind: "todo", todoId, entry }` source. Each root family resolves the Todo's current attachment set at model and tool boundaries; references are never copied into Session messages or storage. `discussion` roots activate `shape-todo`, may update only their source Todo, and may delegate only Explore/Librarian. **Generate / Improve Plan** reuses the latest Discussion only when it is idle, then invokes `plan-work` for the unique `.archcode/plans/.md`. If no Discussion exists, the latest one is busy or suspended, it was deleted, or an idle reuse loses the acceptance race, the action creates a new Discussion whose first accepted message is the Plan request; it never races a generic Discussion start with a second command. Plan existence is not persisted; the Todo Plan endpoint only performs a fixed-path, bounded Markdown read. `work` and `automation` roots may start only from Ready or In Progress. At work creation only, `ProjectTodoService` checks that Plan path: an existing file starts with `execute-plan`, while no file preserves ordinary implementation behavior. Starting from Ready moves the Todo to In Progress, while starting from In Progress leaves it there. A Todo-created Automation stores immutable `{ kind: "todo", todoId, sessionId }` origin; every `start_session` Invocation persists `{ kind: "automation", automationId, invocationId, todoId }`. Direct-origin Invocations persist `todoId: null`. Todo moves never create, stop, rebind, or delete Sessions or Automations. ## HITL -HITL is a durable project-scoped approval/question queue backed by `.archcode/runtime/hitl-queue.json`. Server and Web routes expose redacted `displayPayload` data for approval/Home views; raw sensitive payloads must not be rendered or persisted in UI state. Deferred permission/question flows resolve safely on timeout, cancellation, or shutdown so long-running agent execution is not left hanging. +HITL is a durable project-scoped approval/question queue backed by `.archcode/runtime/hitl-queue.json`. Server and Web routes expose redacted `displayPayload` data for exact approval/question destinations and Todo navigator attention views; raw sensitive payloads must not be rendered or persisted in UI state. Deferred permission/question flows resolve safely on timeout, cancellation, or shutdown so long-running agent execution is not left hanging. ## Automation System diff --git a/apps/server/src/global-work-read-service.ts b/apps/server/src/global-work-read-service.ts index ed14183b..98e36880 100644 --- a/apps/server/src/global-work-read-service.ts +++ b/apps/server/src/global-work-read-service.ts @@ -2,37 +2,17 @@ import type { AgentRuntime, ProjectInfo } from "@archcode/agent-core"; import { projectTodoContentExcerpt, type Automation, - type GlobalSSEHitlEntry, - type HomeResponse, - type HomeSummaryItem, - type ProjectAutomationInventoryItem, - type ProjectSessionInventoryItem, type ProjectTodo, type RootSessionSummary, - type SessionFamilyActivity, type SessionSummary, type WorkbenchProjectReadError, type WorkSearchResponse, type WorkSearchResult, } from "@archcode/protocol"; -import { - readProjectAutomationInventory, - readProjectSessionInventory, -} from "./project-inventory-read"; - -interface ProjectHomeFacts { - readonly project: ProjectInfo; - readonly sessions: ProjectSessionInventoryItem[]; - readonly automations: ProjectAutomationInventoryItem[]; - readonly todos: readonly ProjectTodo[]; - readonly hitl: readonly GlobalSSEHitlEntry[]; - readonly activities: ReadonlyMap>; -} - const SEARCH_LIMIT = 100; -/** Stateless, cross-project Home and global-search composition boundary. */ +/** Stateless cross-project search composition boundary. */ export class GlobalWorkReadService { readonly #runtime: AgentRuntime; @@ -40,35 +20,6 @@ export class GlobalWorkReadService { this.#runtime = runtime; } - async readHome(): Promise { - const projects = await this.#runtime.projectRegistry.list(); - const settled = await Promise.allSettled(projects.map(async (project) => await this.#readProjectHome(project))); - const needsYou: HomeSummaryItem[] = []; - const running: HomeSummaryItem[] = []; - const readyToReview: HomeSummaryItem[] = []; - const upcoming: HomeSummaryItem[] = []; - const projectErrors: WorkbenchProjectReadError[] = []; - - settled.forEach((result, index) => { - const project = projects[index]!; - if (result.status === "rejected") { - projectErrors.push(toProjectError(project, result.reason)); - return; - } - const projection = projectHomeProjection(result.value); - needsYou.push(...projection.needsYou); - running.push(...projection.running); - readyToReview.push(...projection.readyToReview); - upcoming.push(...projection.upcoming); - }); - - needsYou.sort(compareNeedsYou); - running.sort(compareNewestFirst); - readyToReview.sort(compareNewestFirst); - upcoming.sort((left, right) => left.sortAt - right.sortAt || left.href.localeCompare(right.href)); - return { needsYou, running, readyToReview, upcoming, projectErrors }; - } - async search(query: string): Promise { const normalized = query.trim(); const projects = await this.#runtime.projectRegistry.list(); @@ -83,179 +34,34 @@ export class GlobalWorkReadService { .map(requireRootSummary); return { project, sessions, automations, todos: await context.todos.listTodos() }; })); - const matches: WorkSearchResult[] = []; + const results: WorkSearchResult[] = []; const projectErrors: WorkbenchProjectReadError[] = []; settled.forEach((result, index) => { const project = projects[index]!; if (result.status === "rejected") { - projectErrors.push(toProjectError(project, result.reason)); + projectErrors.push(toProjectError(project)); } else { - matches.push(...searchProject(normalized, result.value)); + results.push(...searchProject(normalized, result.value)); } }); return { - results: matches.slice(0, SEARCH_LIMIT), - truncated: matches.length > SEARCH_LIMIT, + results: results.slice(0, SEARCH_LIMIT), + truncated: results.length > SEARCH_LIMIT, projectErrors, }; } - - async #readProjectHome(project: ProjectInfo): Promise { - const [sessions, automations, context, controlPlane] = await Promise.all([ - readProjectSessionInventory(this.#runtime, project.workspaceRoot), - readProjectAutomationInventory(this.#runtime, project.workspaceRoot), - this.#runtime.contextResolver.resolve(project.workspaceRoot), - this.#runtime.getProjectControlPlaneSnapshot(project.workspaceRoot, project.slug), - ]); - return { - project, - sessions, - automations, - todos: await context.todos.listTodos(), - hitl: controlPlane.hitl.entries, - activities: new Map(controlPlane.sessionRuntime.families.map((family) => [ - family.rootSessionId, - family.activity as Exclude, - ])), - }; - } -} - -function projectHomeProjection(facts: ProjectHomeFacts): Omit { - const project = { slug: facts.project.slug, name: facts.project.name }; - const sessionsById = new Map(facts.sessions.map((item) => [item.session.sessionId, item])); - const attentionRootIds = new Set(); - const attentionAutomationIds = new Set(); - const hitlRootIds = new Set(facts.hitl.map((entry) => entry.rootSessionId)); - const needsYou: HomeSummaryItem[] = []; - - for (const entry of [...facts.hitl].sort((left, right) => hitlPriority(left) - hitlPriority(right) || Date.parse(left.view.createdAt) - Date.parse(right.view.createdAt))) { - if (attentionRootIds.has(entry.rootSessionId)) continue; - attentionRootIds.add(entry.rootSessionId); - const inspection = entry.view.requiresInspection === true; - needsYou.push({ - kind: "hitl", - project, - entityId: entry.hitlId, - title: inspection ? "Needs manual inspection" : entry.view.displayPayload.title, - status: inspection ? "inspection" : entry.view.source.type === "tool_permission" ? "permission" : "question", - href: sessionHref(facts.project.slug, entry.rootSessionId, hitlSearch(entry)), - sortAt: Date.parse(entry.view.createdAt), - ...(entry.view.displayPayload.summary === undefined ? {} : { context: entry.view.displayPayload.summary }), - }); - } - - for (const item of facts.sessions) { - const goal = item.session.goal; - if (attentionRootIds.has(item.session.sessionId) || (goal?.status !== "blocked" && goal?.status !== "budget_limited")) continue; - attentionRootIds.add(item.session.sessionId); - needsYou.push({ - kind: "session", - project, - entityId: item.session.sessionId, - title: item.session.title ?? "Untitled session", - status: goal.status, - href: sessionHref(facts.project.slug, item.session.sessionId), - sortAt: goal.updatedAt, - context: goal.blockedReason ?? goal.objective, - }); - } - - for (const item of facts.sessions) { - const execution = item.latestExecution; - if (attentionRootIds.has(item.session.sessionId) || execution === null || (execution.status !== "failed" && execution.status !== "timed_out")) continue; - attentionRootIds.add(item.session.sessionId); - needsYou.push({ - kind: "session", - project, - entityId: item.session.sessionId, - title: item.session.title ?? "Untitled session", - status: execution.status, - href: sessionHref(facts.project.slug, item.session.sessionId), - sortAt: execution.endedAt ?? execution.startedAt, - }); - } - - for (const item of facts.automations) { - const invocation = item.latestInvocation; - if (invocation === null || (invocation.status !== "failed" && invocation.status !== "missed")) continue; - attentionAutomationIds.add(item.automation.id); - needsYou.push({ - kind: "automation", - project, - entityId: item.automation.id, - title: item.automation.name, - status: invocation.status, - href: automationHref(facts.project.slug, item.automation.id, invocation.id), - sortAt: Date.parse(invocation.completedAt ?? invocation.createdAt), - }); - } - - const running = [...facts.activities].flatMap(([rootSessionId, activity]): HomeSummaryItem[] => { - if (attentionRootIds.has(rootSessionId)) return []; - const item = sessionsById.get(rootSessionId); - if (item === undefined) return []; - return [{ - kind: "session", - project, - entityId: rootSessionId, - title: item.session.title ?? "Untitled session", - status: activity, - href: sessionHref(facts.project.slug, rootSessionId), - sortAt: item.session.updatedAt, - context: sourceContext(item.session), - }]; - }); - - const readyToReview = facts.todos.flatMap((todo): HomeSummaryItem[] => { - if (todo.archivedAt !== undefined || todo.status !== "in_progress") return []; - const workSessions = facts.sessions - .filter((item) => ( - item.session.source.kind === "todo" - ? item.session.source.todoId === todo.id && item.session.source.entry === "work" - : item.session.source.kind === "automation" && item.session.source.todoId === todo.id - )) - .sort((left, right) => right.session.updatedAt - left.session.updatedAt || right.session.sessionId.localeCompare(left.session.sessionId)); - if (workSessions.length === 0) return []; - if (workSessions.some((item) => facts.activities.has(item.session.sessionId) - || hitlRootIds.has(item.session.sessionId) - || attentionRootIds.has(item.session.sessionId))) return []; - const latest = workSessions[0]!; - if (latest.latestExecution?.status !== "completed") return []; - return [{ - kind: "todo", - project, - entityId: todo.id, - title: projectTodoContentExcerpt(todo.content), - status: "ready_to_review", - href: todoHref(facts.project.slug, todo.id), - sortAt: Math.max(todo.updatedAt, latest.session.updatedAt), - context: latest.session.title ?? "Completed work Session", - }]; - }); - - const upcoming = facts.automations.flatMap((item): HomeSummaryItem[] => { - if (attentionAutomationIds.has(item.automation.id) || item.automation.status !== "active" || item.automation.nextFireAt === undefined) return []; - return [{ - kind: "automation", - project, - entityId: item.automation.id, - title: item.automation.name, - status: "scheduled", - href: automationHref(facts.project.slug, item.automation.id), - sortAt: Date.parse(item.automation.nextFireAt), - context: item.automation.nextFireAt, - }]; - }); - - return { needsYou, running, readyToReview, upcoming }; } function searchProject( query: string, - facts: { project: ProjectInfo; sessions: readonly RootSessionSummary[]; automations: readonly Automation[]; todos: readonly ProjectTodo[] }, + facts: { + project: ProjectInfo; + sessions: readonly RootSessionSummary[]; + automations: readonly Automation[]; + todos: readonly ProjectTodo[]; + }, ): WorkSearchResult[] { const needle = query.toLocaleLowerCase(); const project = { slug: facts.project.slug, name: facts.project.name }; @@ -265,7 +71,14 @@ function searchProject( } for (const todo of facts.todos) { if (!matches(needle, todo.id, todo.content)) continue; - results.push({ kind: "todo", project, entityId: todo.id, title: projectTodoContentExcerpt(todo.content), href: todoHref(facts.project.slug, todo.id), context: todo.status }); + results.push({ + kind: "todo", + project, + entityId: todo.id, + title: projectTodoContentExcerpt(todo.content), + href: todoHref(facts.project.slug, todo.id), + context: todo.archivedAt === undefined ? todo.status : "archived", + }); } for (const session of facts.sessions) { if (!matches(needle, session.sessionId, session.title, session.source.kind, JSON.stringify(session.source))) continue; @@ -297,32 +110,6 @@ function matches(needle: string, ...values: Array): b return values.some((value) => value?.toLocaleLowerCase().includes(needle) === true); } -function compareNeedsYou(left: HomeSummaryItem, right: HomeSummaryItem): number { - const priority = needsYouPriority(left) - needsYouPriority(right); - return priority || compareNewestFirst(left, right); -} - -function needsYouPriority(item: HomeSummaryItem): number { - if (item.kind === "hitl") return item.status === "inspection" ? 0 : 1; - if (item.kind === "session" && (item.status === "blocked" || item.status === "budget_limited")) return 2; - if (item.kind === "session") return 3; - return 4; -} - -function compareNewestFirst(left: HomeSummaryItem, right: HomeSummaryItem): number { - return right.sortAt - left.sortAt || left.href.localeCompare(right.href); -} - -function hitlPriority(entry: GlobalSSEHitlEntry): number { - return entry.view.requiresInspection === true ? 0 : 1; -} - -function hitlSearch(entry: GlobalSSEHitlEntry): URLSearchParams { - const search = new URLSearchParams({ hitl: entry.hitlId }); - if (entry.ownerSessionId !== entry.rootSessionId) search.set("focus", entry.ownerSessionId); - return search; -} - function projectHref(projectSlug: string): string { return `/projects/${encodeURIComponent(projectSlug)}/todos`; } @@ -331,18 +118,15 @@ function todoHref(projectSlug: string, todoId: string): string { return `${projectHref(projectSlug)}/${encodeURIComponent(todoId)}`; } -function sessionHref(projectSlug: string, sessionId: string, search?: URLSearchParams): string { - const href = `/projects/${encodeURIComponent(projectSlug)}/sessions/${encodeURIComponent(sessionId)}`; - return search === undefined ? href : `${href}?${search.toString()}`; +function sessionHref(projectSlug: string, sessionId: string): string { + return `/projects/${encodeURIComponent(projectSlug)}/sessions/${encodeURIComponent(sessionId)}`; } -function automationHref(projectSlug: string, automationId: string, invocationId?: string): string { - const href = `/projects/${encodeURIComponent(projectSlug)}/automations/${encodeURIComponent(automationId)}`; - return invocationId === undefined ? href : `${href}?invocation=${encodeURIComponent(invocationId)}`; +function automationHref(projectSlug: string, automationId: string): string { + return `/projects/${encodeURIComponent(projectSlug)}/automations/${encodeURIComponent(automationId)}`; } -function toProjectError(project: ProjectInfo, error: unknown): WorkbenchProjectReadError { - void error; +function toProjectError(project: ProjectInfo): WorkbenchProjectReadError { return { project: { slug: project.slug, name: project.name }, message: "Project work is temporarily unavailable", diff --git a/apps/server/src/routes/global-events.test.ts b/apps/server/src/routes/global-events.test.ts index 2ecf0314..3bc92041 100644 --- a/apps/server/src/routes/global-events.test.ts +++ b/apps/server/src/routes/global-events.test.ts @@ -80,7 +80,15 @@ function hitlSnapshot( return { type: "hitl.snapshot", projectSlugs, - entries: views.map((view) => ({ projectSlug: "proj", hitlId: view.hitlId, ownerSessionId: view.owner.id, rootSessionId: "root-1", view })), + entries: views.map((view) => ({ + projectSlug: "proj", + hitlId: view.hitlId, + ownerSessionId: view.owner.id, + rootSessionId: "root-1", + ownerAgentName: "build", + ownerSessionTitle: "Worker Session", + view, + })), createdAt: 0, }; } diff --git a/apps/server/src/routes/global-work.test.ts b/apps/server/src/routes/global-work.test.ts index 065ad054..2edda580 100644 --- a/apps/server/src/routes/global-work.test.ts +++ b/apps/server/src/routes/global-work.test.ts @@ -1,14 +1,7 @@ import { describe, expect, mock, test } from "bun:test"; import { Hono } from "hono"; import type { AgentRuntime, ProjectInfo } from "@archcode/agent-core"; -import type { - Automation, - AutomationInvocation, - GlobalSSEHitlEntry, - ProjectTodo, - RootSessionSummary, - SessionExecutionRecord, -} from "@archcode/protocol"; +import type { Automation, ProjectTodo, SessionSummary } from "@archcode/protocol"; import { errorHandler } from "../error-handler"; import { createGlobalWorkRoutes } from "./global-work"; @@ -26,140 +19,18 @@ const badProject: ProjectInfo = { addedAt: "2026-08-01T00:00:00.000Z", }; -describe("global work read routes", () => { - test("GET /api/home returns server-grouped attention, running, review, and upcoming rows", async () => { - const todo = makeTodo(); - const work = rootSession("work", { kind: "todo", todoId: todo.id, entry: "work" }, 50); - const hitlRoot = rootSession("hitl-root", { kind: "direct" }, 40); - const failure = rootSession("failure", { kind: "direct" }, 30); - const automation = makeAutomation(); - const failedAutomation = makeAutomation({ - id: "33333333-3333-4333-8333-333333333333", - name: "Failing review", - }); - const runtime = makeRuntime({ - projects: [goodProject, badProject], - sessions: [work, hitlRoot, failure], - todos: [todo], - automations: [automation, failedAutomation], - automationInvocations: new Map([[failedAutomation.id, { - id: "44444444-4444-4444-8444-444444444444", - automationId: failedAutomation.id, - dueAt: "2026-08-03T00:00:00.000Z", - status: "failed", - createdAt: "2026-08-03T00:00:00.000Z", - completedAt: "2026-08-03T00:01:00.000Z", - error: "model unavailable", - }]]), - executions: new Map([ - [work.sessionId, [execution("completed", 60)]], - [hitlRoot.sessionId, [execution("running", 41)]], - [failure.sessionId, [execution("failed", 31)]], - ]), - hitl: [hitlEntry(hitlRoot.sessionId)], - activities: new Map([[hitlRoot.sessionId, "running"]]), - failBadProject: true, - }); - const app = new Hono().route("/api", createGlobalWorkRoutes(runtime)); - app.onError(errorHandler); - - const response = await app.request("/api/home"); - const body = await response.json(); - - expect(response.status).toBe(200); - expect(body).toMatchObject({ - needsYou: [ - { kind: "hitl", entityId: "hitl-1", status: "question", project: { slug: "good" } }, - { kind: "session", entityId: "failure", status: "failed", project: { slug: "good" } }, - { kind: "automation", entityId: failedAutomation.id, status: "failed", project: { slug: "good" } }, - ], - running: [], - readyToReview: [{ kind: "todo", entityId: todo.id, status: "ready_to_review" }], - upcoming: [{ kind: "automation", entityId: automation.id, status: "scheduled" }], - projectErrors: [{ project: { slug: "bad", name: "Bad Project" }, message: "Project work is temporarily unavailable" }], - }); - expect(body.readyToReview[0]?.title).toBe("Review the completed work Private PRD body"); - }); - - test("a Goal-attention Session excludes its entire Todo work family from Ready to review", async () => { - const todo = makeTodo(); - const blockedWork: RootSessionSummary = { - ...rootSession("blocked-work", { kind: "todo", todoId: todo.id, entry: "work" }, 40), - goal: { - instanceId: "goal-1", - generation: 1, - objective: "Finish the Todo", - status: "blocked", - usage: { - tokens: { inputTokens: 0, outputTokens: 0, totalTokens: 0, reasoningTokens: 0, cachedInputTokens: 0 }, - executionTimeMs: 0, - executionCount: 0, - }, - settlementReceipts: [], - blockedReason: "Needs a decision", - createdAt: 1, - activatedAt: 1, - updatedAt: 50, - }, - }; - const completedWork = rootSession("completed-work", { kind: "todo", todoId: todo.id, entry: "work" }, 60); - const runtime = makeRuntime({ - projects: [goodProject], - sessions: [blockedWork, completedWork], - todos: [todo], - automations: [], - executions: new Map([[completedWork.sessionId, [execution("completed", 61)]]]), - }); - const app = new Hono().route("/api", createGlobalWorkRoutes(runtime)); - app.onError(errorHandler); - - const response = await app.request("/api/home"); - const body = await response.json(); - - expect(response.status).toBe(200); - expect(body.needsYou).toEqual([ - expect.objectContaining({ entityId: blockedWork.sessionId, status: "blocked" }), - ]); - expect(body.readyToReview).toEqual([]); - }); - - test("completed Todo-originated Automation work is ready for Todo review", async () => { - const todo = makeTodo(); - const session = rootSession("automation-work", { - kind: "automation", - automationId: "11111111-1111-4111-8111-111111111111", - invocationId: "22222222-2222-4222-8222-222222222222", - todoId: todo.id, - }, 60); - const runtime = makeRuntime({ - projects: [goodProject], - sessions: [session], - todos: [todo], - automations: [], - executions: new Map([[session.sessionId, [execution("completed", 61)]]]), - }); - const app = new Hono().route("/api", createGlobalWorkRoutes(runtime)); - app.onError(errorHandler); - - const body = await (await app.request("/api/home")).json(); - expect(body.readyToReview).toEqual([ - expect.objectContaining({ kind: "todo", entityId: todo.id, status: "ready_to_review" }), - ]); - }); - +describe("global work search route", () => { test("GET /api/search matches Todo content, caps at 100, and isolates project failures", async () => { - const todos = Array.from({ length: 101 }, (_, index) => makeTodo({ + const todos = Array.from({ length: 101 }, (_, index): ProjectTodo => ({ id: `00000000-0000-4000-8000-${String(index).padStart(12, "0")}`, content: `Todo ${index}\n\nhidden needle content`, + attachmentIds: [], + status: "in_progress", + revision: 1, + createdAt: index, + updatedAt: index, })); - const runtime = makeRuntime({ - projects: [goodProject, badProject], - sessions: [], - todos, - automations: [], - executions: new Map(), - failBadProject: true, - }); + const runtime = makeRuntime([goodProject, badProject], todos, true); const app = new Hono().route("/api", createGlobalWorkRoutes(runtime)); app.onError(errorHandler); @@ -182,7 +53,7 @@ describe("global work read routes", () => { }); test("GET /api/search enforces the trimmed 1-200 character boundary", async () => { - const runtime = makeRuntime({ projects: [], sessions: [], todos: [], automations: [], executions: new Map() }); + const runtime = makeRuntime([], [], false); const app = new Hono().route("/api", createGlobalWorkRoutes(runtime)); app.onError(errorHandler); @@ -194,155 +65,96 @@ describe("global work read routes", () => { expect(twoHundred.status).toBe(200); expect(twoHundredOne.status).toBe(400); }); -}); -function makeRuntime(input: { - projects: ProjectInfo[]; - sessions: RootSessionSummary[]; - todos: ProjectTodo[]; - automations: Automation[]; - executions: Map; - automationInvocations?: Map; - hitl?: GlobalSSEHitlEntry[]; - activities?: Map; - failBadProject?: boolean; -}): AgentRuntime { - return { - projectRegistry: { list: mock(async () => input.projects) }, - listSessions: mock(async (workspaceRoot: string) => { - if (input.failBadProject && workspaceRoot === badProject.workspaceRoot) throw new Error("corrupt project"); - return input.sessions; - }), - listSessionInventory: mock(async (workspaceRoot: string) => { - if (input.failBadProject && workspaceRoot === badProject.workspaceRoot) throw new Error("corrupt project"); - return input.sessions.map((session) => { - const latest = input.executions.get(session.sessionId)?.at(-1); - return { - session, - latestExecution: latest === undefined - ? null - : { - id: latest.id, - status: latest.status, - startedAt: latest.startedAt, - ...("endedAt" in latest ? { endedAt: latest.endedAt } : {}), - }, - }; - }); - }), - getSessionFile: mock(async (_workspaceRoot: string, sessionId: string) => ({ - ...input.sessions.find((session) => session.sessionId === sessionId)!, - executions: input.executions.get(sessionId) ?? [], - })), - listAutomations: mock(async (workspaceRoot: string) => { - if (input.failBadProject && workspaceRoot === badProject.workspaceRoot) throw new Error("corrupt project"); - return input.automations; - }), - listAutomationInventory: mock(async (workspaceRoot: string) => { - if (input.failBadProject && workspaceRoot === badProject.workspaceRoot) throw new Error("corrupt project"); - return input.automations.map((automation) => ({ - automation, - latestInvocation: input.automationInvocations?.get(automation.id) ?? null, - })); - }), - listAutomationInvocations: mock(async () => []), - contextResolver: { resolve: mock(async (workspaceRoot: string) => { - if (input.failBadProject && workspaceRoot === badProject.workspaceRoot) throw new Error("corrupt project"); - return { todos: { listTodos: mock(async () => input.todos) } }; - }) }, - getProjectControlPlaneSnapshot: mock(async (workspaceRoot: string) => { - if (input.failBadProject && workspaceRoot === badProject.workspaceRoot) throw new Error("corrupt project"); - return { - sessionRuntime: { - type: "session.runtime.snapshot", - projectSlugs: [goodProject.slug], - families: [...(input.activities ?? new Map())].map(([rootSessionId, activity]) => ({ - projectSlug: goodProject.slug, - rootSessionId, - activity, - })), - createdAt: 1, - }, - hitl: { - type: "hitl.snapshot", - projectSlugs: [goodProject.slug], - entries: input.hitl ?? [], - createdAt: 1, - }, - }; - }), - } as unknown as AgentRuntime; -} + test("GET /api/search returns exact deep links for every current work surface", async () => { + const project = { ...goodProject, slug: "needle-project", name: "Needle Project" }; + const todo: ProjectTodo = { + id: "11111111-1111-4111-8111-111111111111", + content: "Needle Todo", + attachmentIds: [], + status: "ready", + revision: 1, + createdAt: 1, + updatedAt: 1, + }; + const session: SessionSummary = { + sessionId: "22222222-2222-4222-8222-222222222222", + cwd: project.workspaceRoot, + rootSessionId: "22222222-2222-4222-8222-222222222222", + agentName: "lead", + profile: "principal", + activeSkillNames: [], + modelSelection: { revision: 0 }, + title: "Needle Session", + source: { kind: "direct" }, + createdAt: 1, + updatedAt: 1, + }; + const automation: Automation = { + id: "33333333-3333-4333-8333-333333333333", + projectSlug: project.slug, + origin: { kind: "direct" }, + name: "Needle Automation", + trigger: { kind: "interval", everyMs: 60_000 }, + action: { kind: "start_session", message: "Needle run", location: "project" }, + status: "active", + createdAt: "2026-08-17T00:00:00.000Z", + updatedAt: "2026-08-17T00:00:00.000Z", + }; + const runtime = makeRuntime([project], [todo], false, [session], [automation]); + const app = new Hono().route("/api", createGlobalWorkRoutes(runtime)); -function rootSession(sessionId: string, source: RootSessionSummary["source"], updatedAt: number): RootSessionSummary { - return { - sessionId, - cwd: goodProject.workspaceRoot, - rootSessionId: sessionId, - agentName: "lead", - profile: "principal", - activeSkillNames: [], - modelSelection: { revision: 0 }, - title: sessionId, - source, - createdAt: 1, - updatedAt, - }; -} + const response = await app.request("/api/search?q=needle"); + const body = await response.json() as { results: Array<{ kind: string; href: string }> }; -function execution(status: "running" | "completed" | "failed", startedAt: number): SessionExecutionRecord { - return { - id: `execution-${startedAt}`, - status, - startedAt, - ...(status === "running" ? {} : { endedAt: startedAt + 1 }), - } as SessionExecutionRecord; -} + expect(response.status).toBe(200); + expect(body.results.map(({ kind, href }) => ({ kind, href }))).toEqual([ + { kind: "project", href: "/projects/needle-project/todos" }, + { kind: "todo", href: `/projects/needle-project/todos/${todo.id}` }, + { kind: "session", href: `/projects/needle-project/sessions/${session.sessionId}` }, + { kind: "automation", href: `/projects/needle-project/automations/${automation.id}` }, + ]); + }); -function makeTodo(overrides: Partial = {}): ProjectTodo { - return { - id: "11111111-1111-4111-8111-111111111111", - content: "Review the completed work\n\nPrivate PRD body", - attachmentIds: [], - status: "in_progress", - revision: 1, - createdAt: 1, - updatedAt: 70, - ...overrides, - }; -} + test("GET /api/search presents an archived Todo by its effective lifecycle", async () => { + const todo: ProjectTodo = { + id: "44444444-4444-4444-8444-444444444444", + content: "Archived needle Todo", + attachmentIds: [], + status: "in_progress", + archivedAt: 2, + revision: 2, + createdAt: 1, + updatedAt: 2, + }; + const runtime = makeRuntime([goodProject], [todo], false); + const app = new Hono().route("/api", createGlobalWorkRoutes(runtime)); -function makeAutomation(overrides: Partial = {}): Automation { - return { - id: "22222222-2222-4222-8222-222222222222", - projectSlug: goodProject.slug, - origin: { kind: "direct" }, - name: "Nightly review", - trigger: { kind: "cron", expression: "0 1 * * *", timezone: "UTC" }, - action: { kind: "start_session", message: "Review", location: "project" }, - status: "active", - createdAt: "2026-08-01T00:00:00.000Z", - updatedAt: "2026-08-01T00:00:00.000Z", - nextFireAt: "2026-08-04T01:00:00.000Z", - ...overrides, - }; -} + const response = await app.request("/api/search?q=archived"); + const body = await response.json() as { results: Array<{ context?: string }> }; + + expect(response.status).toBe(200); + expect(body.results).toHaveLength(1); + expect(body.results[0]?.context).toBe("archived"); + }); +}); -function hitlEntry(rootSessionId: string): GlobalSSEHitlEntry { +function makeRuntime( + projects: readonly ProjectInfo[], + todos: readonly ProjectTodo[], + failBadProject: boolean, + sessions: readonly SessionSummary[] = [], + automations: readonly Automation[] = [], +): AgentRuntime { return { - projectSlug: goodProject.slug, - hitlId: "hitl-1", - ownerSessionId: rootSessionId, - rootSessionId, - view: { - hitlId: "hitl-1", - owner: { type: "session", id: rootSessionId }, - source: { type: "ask_user", toolCallId: "question-1" }, - status: "pending", - displayPayload: { title: "Choose an option", summary: "Waiting for an answer", redacted: true }, - allowedActions: ["answer", "cancel"], - createdAt: "2026-08-03T00:00:00.000Z", - updatedAt: "2026-08-03T00:00:00.000Z", + projectRegistry: { list: mock(async () => [...projects]) }, + listSessions: mock(async (workspaceRoot: string) => { + if (failBadProject && workspaceRoot === badProject.workspaceRoot) throw new Error("corrupt project"); + return [...sessions]; + }), + listAutomations: mock(async () => [...automations]), + contextResolver: { + resolve: mock(async () => ({ todos: { listTodos: mock(async () => [...todos]) } })), }, - }; + } as unknown as AgentRuntime; } diff --git a/apps/server/src/routes/global-work.ts b/apps/server/src/routes/global-work.ts index 1b672da0..fc4c6be5 100644 --- a/apps/server/src/routes/global-work.ts +++ b/apps/server/src/routes/global-work.ts @@ -13,7 +13,6 @@ export function createGlobalWorkRoutes(runtime: AgentRuntime): Hono { const app = new Hono(); const reads = new GlobalWorkReadService(runtime); - app.get("/home", async (c) => c.json(await reads.readHome())); app.get("/search", zValidator("query", GlobalSearchQuerySchema), async (c) => ( c.json(await reads.search(c.req.valid("query").q)) )); diff --git a/apps/server/src/routes/todos.test.ts b/apps/server/src/routes/todos.test.ts index f1c86b6c..06a7701b 100644 --- a/apps/server/src/routes/todos.test.ts +++ b/apps/server/src/routes/todos.test.ts @@ -9,6 +9,7 @@ import type { ProjectTodo, ProjectTodoCreateInput, ProjectTodoRunNowInput, + ProjectTodoStartDiscussionInput, ProjectTodoUpdateInput, } from "@archcode/protocol"; import type { AgentRuntime } from "@archcode/agent-core"; @@ -192,6 +193,35 @@ describe("Project Todo routes", () => { expect(fixture.runNow).toHaveBeenCalledWith(input); }); + test("starts a captured Discussion through the single backend-owned command", async () => { + const todo = makeTodo(); + const fixture = createFixture(todo); + const input: ProjectTodoStartDiscussionInput = { + clientRequestId: crypto.randomUUID(), + content: "Discuss now\n\nShape it.", + }; + + const response = await fixture.app.request( + `/api/projects/${fixture.project.slug}/todos/start-discussion`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(input), + }, + ); + + expect(response.status).toBe(201); + expect(await response.json()).toMatchObject({ + todo, + session: { + sessionId: "11111111-1111-4111-8111-111111111111", + agentName: "discussion", + source: { kind: "todo", todoId: todo.id, entry: "discussion" }, + }, + }); + expect(fixture.startDiscussion).toHaveBeenCalledWith(input); + }); + test("strictly validates flat mutation and Session request bodies", async () => { const fixture = createFixture(makeTodo()); const base = `/api/projects/${fixture.project.slug}/todos`; @@ -226,13 +256,24 @@ describe("Project Todo routes", () => { headers, body: JSON.stringify({ clientRequestId: crypto.randomUUID(), content: "Start", origin: "forged" }), }); + const invalidStartDiscussion = await fixture.app.request(`${base}/start-discussion`, { + method: "POST", + headers, + body: JSON.stringify({ + clientRequestId: crypto.randomUUID(), + content: "Discuss", + expectedRevision: 1, + }), + }); expect(emptyMutation.status).toBe(400); expect(mixedArchive.status).toBe(400); expect(invalidEntry.status).toBe(400); expect(invalidPlanIntent.status).toBe(400); expect(invalidRunNow.status).toBe(400); + expect(invalidStartDiscussion.status).toBe(400); expect(fixture.runNow).not.toHaveBeenCalled(); + expect(fixture.startDiscussion).not.toHaveBeenCalled(); }); test("maps Run now key conflicts and recovery-required failures", async () => { @@ -266,6 +307,50 @@ describe("Project Todo routes", () => { }); }); + test("maps Start discussion key conflicts and recovery-required failures with exact ids", async () => { + const fixture = createFixture(makeTodo()); + const path = `/api/projects/${fixture.project.slug}/todos/start-discussion`; + const clientRequestId = crypto.randomUUID(); + const request = { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ clientRequestId, content: "Discuss" }), + }; + fixture.startDiscussion.mockRejectedValueOnce(Object.assign(new Error("different input"), { + code: "PROJECT_TODO_START_DISCUSSION_CONFLICT", + clientRequestId, + })); + const conflict = await fixture.app.request(path, request); + fixture.startDiscussion.mockRejectedValueOnce(Object.assign(new Error("manual recovery"), { + code: "PROJECT_TODO_START_DISCUSSION_RECOVERY_REQUIRED", + todoId: fixture.todo.id, + sessionId: "11111111-1111-4111-8111-111111111111", + })); + const recovery = await fixture.app.request(path, request); + + expect(conflict.status).toBe(409); + expect(await conflict.json()).toMatchObject({ + error: { + code: "BAD_REQUEST", + details: { + scopeCode: "PROJECT_TODO_START_DISCUSSION_CONFLICT", + clientRequestId, + }, + }, + }); + expect(recovery.status).toBe(500); + expect(await recovery.json()).toMatchObject({ + error: { + code: "INTERNAL_ERROR", + details: { + scopeCode: "PROJECT_TODO_START_DISCUSSION_RECOVERY_REQUIRED", + todoId: fixture.todo.id, + sessionId: "11111111-1111-4111-8111-111111111111", + }, + }, + }); + }); + test("maps not-found and current-domain conflicts", async () => { const fixture = createFixture(makeTodo()); const base = `/api/projects/${fixture.project.slug}/todos/${fixture.todo.id}`; @@ -408,6 +493,22 @@ function createFixture(todo: ProjectTodo) { updatedAt: todo.updatedAt, }, })), + startDiscussion: mock(async (_input: ProjectTodoStartDiscussionInput) => ({ + todo, + session: { + sessionId: "11111111-1111-4111-8111-111111111111", + cwd: workspaceRoot, + rootSessionId: "11111111-1111-4111-8111-111111111111", + agentName: "discussion" as const, + profile: "principal" as const, + activeSkillNames: [], + modelSelection: { revision: 0 }, + title: "Discussion: Capture an idea", + source: { kind: "todo" as const, todoId: todo.id, entry: "discussion" as const }, + createdAt: todo.createdAt, + updatedAt: todo.updatedAt, + }, + })), } satisfies ProjectTodoServiceLike; const runtime = { projectRegistry: { get: mock(async (slug: string) => slug === project.slug ? project : undefined) }, diff --git a/apps/server/src/routes/todos.ts b/apps/server/src/routes/todos.ts index 51313e40..1c2c3c77 100644 --- a/apps/server/src/routes/todos.ts +++ b/apps/server/src/routes/todos.ts @@ -14,6 +14,8 @@ import type { ProjectTodoPlanResponse, ProjectTodoRunNowInput, ProjectTodoRunNowResponse, + ProjectTodoStartDiscussionInput, + ProjectTodoStartDiscussionResponse, ProjectTodoUpdateInput, } from "@archcode/protocol"; import { @@ -23,6 +25,7 @@ import { import { CreateProjectTodoSessionSchema, ProjectTodoRunNowSchema, + ProjectTodoStartDiscussionSchema, type AgentRuntime, } from "@archcode/agent-core"; import { z } from "zod/v4"; @@ -94,6 +97,9 @@ export interface ProjectTodoServiceLike { input: CreateProjectTodoSessionInput, ): Promise; runNow(input: ProjectTodoRunNowInput): Promise; + startDiscussion( + input: ProjectTodoStartDiscussionInput, + ): Promise; } export function createTodosRoutes(runtime: AgentRuntime): Hono { @@ -139,6 +145,21 @@ export function createTodosRoutes(runtime: AgentRuntime): Hono { }, ); + app.post( + "/:slug/todos/start-discussion", + zValidator("param", ProjectTodoListParamsSchema), + zValidator("json", ProjectTodoStartDiscussionSchema), + async (c) => { + const project = await resolveProject(runtime, c.req.valid("param").slug); + const service = await resolveTodos(runtime, project.workspaceRoot); + try { + return c.json(await service.startDiscussion(c.req.valid("json")), 201); + } catch (error) { + throw mapTodoError(error); + } + }, + ); + app.get( "/:slug/todos/:todoId/attachments", zValidator("param", ProjectTodoParamsSchema), @@ -361,6 +382,12 @@ function mapTodoError(error: unknown): Error { clientRequestId: "clientRequestId" in error ? error.clientRequestId : undefined, }); } + if (hasCode(error, "PROJECT_TODO_START_DISCUSSION_CONFLICT")) { + return new ServerError("BAD_REQUEST", error.message, 409, { + scopeCode: error.code, + clientRequestId: "clientRequestId" in error ? error.clientRequestId : undefined, + }); + } if (hasCode(error, "PROJECT_TODO_RUN_NOW_RECOVERY_REQUIRED")) { return new ServerError("INTERNAL_ERROR", error.message, 500, { scopeCode: error.code, @@ -368,6 +395,13 @@ function mapTodoError(error: unknown): Error { sessionId: "sessionId" in error ? error.sessionId : undefined, }); } + if (hasCode(error, "PROJECT_TODO_START_DISCUSSION_RECOVERY_REQUIRED")) { + return new ServerError("INTERNAL_ERROR", error.message, 500, { + scopeCode: error.code, + todoId: error.todoId, + sessionId: "sessionId" in error ? error.sessionId : undefined, + }); + } if (isProjectTodoConflict(error)) { return new ServerError(error.code, error.message, 409, error); } diff --git a/apps/web/index.html b/apps/web/index.html index 6e65c907..96351c54 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -5,17 +5,11 @@ - + ArchCode - - -
- \ No newline at end of file + diff --git a/apps/web/package.json b/apps/web/package.json index fea541f5..bf3023a8 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -9,7 +9,7 @@ "typecheck": "tsc --noEmit", "preview": "vite preview", "test": "bun test --isolate && bun run test:interaction", - "test:interaction": "bun test --isolate ./src/routes/root-layout.interaction.tsx ./src/routes/project-todos.interaction.tsx ./src/context/workbench-layout.interaction.tsx ./src/components/primitives/IconAction.interaction.tsx ./src/components/primitives/StatusTransition.interaction.tsx ./src/components/features/ResizeHandle.interaction.tsx ./src/components/features/TodoProgressButton.interaction.tsx ./src/components/features/DiffTab.interaction.tsx ./src/components/features/ContextInspector.interaction.tsx ./src/components/features/InspectorToggleButton.interaction.tsx ./src/components/features/SettingsDialog.interaction.tsx ./src/components/features/SettingsRuntimeDataPanel.interaction.tsx ./src/components/features/SettingsConfigRecoveryPanel.interaction.tsx ./src/components/features/EditAutomationDialog.interaction.tsx ./src/components/features/SessionComposerDock.interaction.tsx ./src/components/features/ComposerQueueList.interaction.tsx ./src/components/features/SessionGoalSummaryRow.interaction.tsx ./src/components/features/HitlBell.interaction.tsx ./src/components/features/AddProjectModal.interaction.tsx ./src/components/composite/ExecutionWorkstream.interaction.tsx ./src/components/composite/ToolCard.interaction.tsx" + "test:interaction": "bun test --isolate ./src/routes/root-layout.interaction.tsx ./src/routes/project-todos.interaction.tsx ./src/routes/project-sessions.interaction.tsx ./src/context/workbench-layout.interaction.tsx ./src/components/primitives/IconAction.interaction.tsx ./src/components/primitives/StatusTransition.interaction.tsx ./src/components/features/ProjectTodoCaptureDialog.interaction.tsx ./src/components/features/ResizeHandle.interaction.tsx ./src/components/features/TodoProgressButton.interaction.tsx ./src/components/features/DiffTab.interaction.tsx ./src/components/features/ContextInspector.interaction.tsx ./src/components/features/InspectorToggleButton.interaction.tsx ./src/components/features/SettingsDialog.interaction.tsx ./src/components/features/SettingsRuntimeDataPanel.interaction.tsx ./src/components/features/SettingsConfigRecoveryPanel.interaction.tsx ./src/components/features/EditAutomationDialog.interaction.tsx ./src/components/features/SessionComposerDock.interaction.tsx ./src/components/features/ComposerQueueList.interaction.tsx ./src/components/features/SessionGoalSummaryRow.interaction.tsx ./src/components/features/HitlBell.interaction.tsx ./src/components/features/AddProjectModal.interaction.tsx ./src/components/composite/ExecutionWorkstream.interaction.tsx ./src/components/composite/ToolCard.interaction.tsx" }, "dependencies": { "@archcode/protocol": "workspace:*", diff --git a/apps/web/public/apple-touch-icon.png b/apps/web/public/apple-touch-icon.png index 454565d4..5fde963a 100644 Binary files a/apps/web/public/apple-touch-icon.png and b/apps/web/public/apple-touch-icon.png differ diff --git a/apps/web/public/favicon.ico b/apps/web/public/favicon.ico index 160552db..31023c48 100644 Binary files a/apps/web/public/favicon.ico and b/apps/web/public/favicon.ico differ diff --git a/apps/web/public/logo.svg b/apps/web/public/logo.svg index 973e20e2..2dfa50ed 100644 --- a/apps/web/public/logo.svg +++ b/apps/web/public/logo.svg @@ -1,7 +1,6 @@ - - + - \ No newline at end of file + diff --git a/apps/web/src/api/automations.test.ts b/apps/web/src/api/automations.test.ts index 833cbc40..a1ce4296 100644 --- a/apps/web/src/api/automations.test.ts +++ b/apps/web/src/api/automations.test.ts @@ -37,12 +37,11 @@ describe("Automation API queries", () => { }); }); -test("Automation invalidation refreshes list, Home, detail, and history", async () => { +test("Automation invalidation refreshes list, detail, and history", async () => { const calls: unknown[] = []; await invalidateAutomation({ invalidateQueries: async (input) => { calls.push(input.queryKey); } }, "demo", "a1"); expect(calls).toEqual([ queryKeys.projectAutomations("demo"), - queryKeys.home, queryKeys.automation("demo", "a1"), queryKeys.automationInvocations("demo", "a1"), ]); diff --git a/apps/web/src/api/mutations.test.ts b/apps/web/src/api/mutations.test.ts index 9e3e3604..65e7688f 100644 --- a/apps/web/src/api/mutations.test.ts +++ b/apps/web/src/api/mutations.test.ts @@ -12,14 +12,13 @@ afterEach(() => { }); describe("project catalog mutation invalidation", () => { - test("refreshes both the Project Rail and global Home projection", async () => { + test("refreshes the Project Rail", async () => { const invalidateQueries = mock(async () => undefined); await invalidateProjectCatalog({ invalidateQueries } as never); - expect(invalidateQueries).toHaveBeenCalledTimes(2); + expect(invalidateQueries).toHaveBeenCalledTimes(1); expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: queryKeys.projects }); - expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: queryKeys.home }); }); }); diff --git a/apps/web/src/api/mutations.ts b/apps/web/src/api/mutations.ts index d04f4b94..1786eaa7 100644 --- a/apps/web/src/api/mutations.ts +++ b/apps/web/src/api/mutations.ts @@ -23,6 +23,7 @@ import type { SessionSummary, ProjectSessionInventoryItem, ProjectTodoRunNowResponse, + ProjectTodoStartDiscussionResponse, Automation, AutomationAction, AutomationTrigger, @@ -37,10 +38,7 @@ import { createClientUuid } from "../lib/client-uuid"; export async function invalidateProjectCatalog( queryClient: Pick, ): Promise { - await Promise.all([ - queryClient.invalidateQueries({ queryKey: queryKeys.projects }), - queryClient.invalidateQueries({ queryKey: queryKeys.home }), - ]); + await queryClient.invalidateQueries({ queryKey: queryKeys.projects }); } export function useUpdateProjectName() { @@ -147,7 +145,6 @@ export function useDeleteSession() { void Promise.all([ queryClient.invalidateQueries({ queryKey: queryKeys.sessions(variables.slug) }), queryClient.invalidateQueries({ queryKey: queryKeys.projectTodos(variables.slug) }), - queryClient.invalidateQueries({ queryKey: queryKeys.home }), ]); }, }); @@ -360,7 +357,6 @@ async function invalidateSessionGoalQueries(queryClient: QueryClient, slug: stri await Promise.all([ queryClient.invalidateQueries({ queryKey: queryKeys.session(slug, sessionId) }), queryClient.invalidateQueries({ queryKey: queryKeys.sessions(slug) }), - queryClient.invalidateQueries({ queryKey: queryKeys.home }), ]); } @@ -419,7 +415,6 @@ export function invalidateAutomation( ): Promise { return Promise.all([ qc.invalidateQueries({ queryKey: queryKeys.projectAutomations(slug) }), - qc.invalidateQueries({ queryKey: queryKeys.home }), ...(automationId === undefined ? [] : [ qc.invalidateQueries({ queryKey: queryKeys.automation(slug, automationId) }), qc.invalidateQueries({ queryKey: queryKeys.automationInvocations(slug, automationId) }), @@ -587,7 +582,6 @@ async function invalidateProjectTodoSession( await Promise.all([ invalidateProjectTodo(queryClient, slug), queryClient.invalidateQueries({ queryKey: queryKeys.sessions(slug) }), - queryClient.invalidateQueries({ queryKey: queryKeys.home }), queryClient.invalidateQueries({ queryKey: queryKeys.projectAutomations(slug) }), ]); } @@ -620,6 +614,27 @@ export function useRunProjectTodoNow() { }); } +export function useStartProjectTodoDiscussion() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ + slug, + clientRequestId, + content, + }: { + slug: string; + clientRequestId: string; + content: string; + }) => apiFetch(`${todoUrl(slug)}/start-discussion`, { + method: "POST", + body: { clientRequestId, content }, + }), + onSettled: async (_data, _error, variables) => { + await invalidateProjectTodoSession(queryClient, variables.slug); + }, + }); +} + export function useUpdateProjectTodo() { const queryClient = useQueryClient(); return useMutation({ diff --git a/apps/web/src/api/queries.ts b/apps/web/src/api/queries.ts index ce519a7e..5f78c9ae 100644 --- a/apps/web/src/api/queries.ts +++ b/apps/web/src/api/queries.ts @@ -1,6 +1,6 @@ import { queryOptions, useQuery } from "@tanstack/react-query"; import { apiFetch } from "./client"; -import type { AgentDescriptor, HomeResponse, ModelRuntimeCatalog, WorkSearchResponse } from "@archcode/protocol"; +import type { AgentDescriptor, ModelRuntimeCatalog, WorkSearchResponse } from "@archcode/protocol"; import type { DiffFile, DirectoryListResponse, @@ -26,7 +26,6 @@ export const queryKeys = { modelRuntime: ["config", "model-runtime"] as const, update: ["update"] as const, projects: ["projects"] as const, - home: ["home"] as const, workSearch: (query: string) => ["search", query] as const, sessions: (slug: string) => ["projects", slug, "sessions"] as const, session: (slug: string, sessionId: string) => ["projects", slug, "sessions", sessionId] as const, @@ -70,10 +69,6 @@ export function projectsQueryOptions() { }); } -export function homeQueryOptions() { - return queryOptions({ queryKey: queryKeys.home, queryFn: () => apiFetch("/api/home") }); -} - export function workSearchQueryOptions(query: string) { const normalized = query.trim(); return queryOptions({ @@ -149,10 +144,6 @@ export function useProjects() { return useQuery(projectsQueryOptions()); } -export function useHome() { - return useQuery(homeQueryOptions()); -} - export function useWorkSearch(query: string) { return useQuery(workSearchQueryOptions(query)); } diff --git a/apps/web/src/api/types.ts b/apps/web/src/api/types.ts index f4066fdf..554b0f51 100644 --- a/apps/web/src/api/types.ts +++ b/apps/web/src/api/types.ts @@ -9,6 +9,7 @@ export type { ProjectSessionInventoryItem, ProjectAutomationInventoryItem, ProjectTodoRunNowResponse, + ProjectTodoStartDiscussionResponse, SessionTreeResponse, SessionTreeNode, SessionTreeDiagnostic, @@ -47,6 +48,7 @@ export type { ProjectTodoCreateInput, ProjectTodoUpdateInput, CreateProjectTodoSessionInput, + ProjectTodoStartDiscussionInput, } from "@archcode/protocol"; import type { @@ -67,7 +69,7 @@ export interface ProjectTodoPlan { /** Complete persisted Session file returned by the Session detail endpoint. */ export type Session = ProtocolSession & Pick; -/** Visible Session-owned Goal projection returned by Session and Home APIs. */ +/** Visible Session-owned Goal projection returned by Session APIs. */ export type SessionGoalView = SessionGoal; export type SessionWithGoal = Session & { goal?: SessionGoalView }; diff --git a/apps/web/src/components/bootstrap/BootstrapGate.tsx b/apps/web/src/components/bootstrap/BootstrapGate.tsx index afc54ddd..43af89c8 100644 --- a/apps/web/src/components/bootstrap/BootstrapGate.tsx +++ b/apps/web/src/components/bootstrap/BootstrapGate.tsx @@ -13,8 +13,8 @@ import { Field, TextInput } from "../features/settings-fields"; type GateState = { kind: "loading" } | { kind: "error"; message: string } | { kind: "status"; status: BootstrapStatus }; -const primaryButton = "inline-flex h-9 items-center justify-center gap-2 rounded-sm bg-brand px-4 text-[12px] font-semibold text-brand-ink transition-colors duration-[var(--motion-hover)] hover:bg-brand-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand disabled:cursor-not-allowed disabled:opacity-40"; -const secondaryButton = "inline-flex h-9 items-center justify-center gap-2 rounded-sm bg-bg-active px-4 text-[12px] font-semibold text-text-secondary transition-colors duration-[var(--motion-hover)] hover:bg-bg-hover hover:text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand disabled:cursor-not-allowed disabled:opacity-40"; +const primaryButton = "inline-flex h-9 items-center justify-center gap-2 rounded-sm bg-brand px-4 text-[12px] font-semibold text-brand-ink transition-colors duration-[var(--motion-fast)] hover:bg-brand-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand disabled:cursor-not-allowed disabled:opacity-40"; +const secondaryButton = "inline-flex h-9 items-center justify-center gap-2 rounded-sm bg-bg-active px-4 text-[12px] font-semibold text-text-secondary transition-colors duration-[var(--motion-fast)] hover:bg-bg-hover hover:text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand disabled:cursor-not-allowed disabled:opacity-40"; let terminalGrantFromFragment: string | undefined; diff --git a/apps/web/src/components/composite/CompressionBlock.tsx b/apps/web/src/components/composite/CompressionBlock.tsx index 634774e3..bc942391 100644 --- a/apps/web/src/components/composite/CompressionBlock.tsx +++ b/apps/web/src/components/composite/CompressionBlock.tsx @@ -97,26 +97,26 @@ export function CompressionBlock({ part, projectSlug, sessionId, focusStoreSessi const protectedCount = snapshot?.protectedRefs?.length; return ( -
-
-