From fd3b76aaa198c71ed1c0d6beafed527f87743321 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Tue, 20 Oct 2026 15:14:02 -0700 Subject: [PATCH 1/2] refactor(web): delete dead chat, bench, wire, and diff modules --- apps/web/src/approval-actions.ts | 11 - apps/web/src/bench/index.ts | 18 -- apps/web/src/bench/membership.ts | 13 - apps/web/src/bench/tenancy-contracts.ts | 120 -------- apps/web/src/chat-artifact-open.ts | 89 ------ apps/web/src/chat/agent-part-adapter.ts | 10 - apps/web/src/chat/blocks/approval-actions.ts | 7 +- apps/web/src/chat/blocks/approve-block.tsx | 5 +- .../chat/blocks/connect-service-actions.ts | 35 --- .../connect-service-block-container.tsx | 63 ---- .../src/chat/blocks/connect-service-block.tsx | 197 ------------ apps/web/src/chat/blocks/metrics-block.tsx | 36 --- apps/web/src/chat/blocks/registry.tsx | 74 ----- apps/web/src/chat/blocks/steps-block.tsx | 19 -- apps/web/src/chat/blocks/stream-block.tsx | 14 - apps/web/src/chat/clock.ts | 13 - apps/web/src/chat/failed-turn-models.ts | 61 ---- apps/web/src/chat/index.ts | 37 +-- apps/web/src/chat/inference-failure.ts | 8 +- apps/web/src/chat/relative-time.ts | 15 - apps/web/src/chat/resume-failed-banner.tsx | 25 -- apps/web/src/chat/wire/agent-address.ts | 14 - apps/web/src/chat/wire/blocks.ts | 115 +------ .../src/chat/wire/consumer-inference-text.ts | 62 ---- apps/web/src/chat/wire/display-name.ts | 2 - apps/web/src/chat/wire/id-leak-guard.ts | 3 +- apps/web/src/chat/wire/mentions.ts | 32 -- apps/web/src/chat/wire/participants.ts | 87 ------ apps/web/src/chat/wire/parts.ts | 93 ------ apps/web/src/chat/wire/stream-events.ts | 56 ---- apps/web/src/chat/wire/tools-unsupported.ts | 13 - apps/web/src/insights-timeline.ts | 75 ----- apps/web/src/lib/text-diff/index.ts | 11 - apps/web/src/lib/text-diff/line-diff.test.ts | 156 ---------- apps/web/src/lib/text-diff/line-diff.ts | 285 ------------------ apps/web/src/pages/diff-view.css | 114 ------- apps/web/src/pages/diff-view.tsx | 108 ------- apps/web/src/react-ui-toast-mock.ts | 29 -- apps/web/src/settings/index.ts | 55 +--- .../src/settings/notifications-section.tsx | 86 ------ apps/web/src/settings/section-registry.tsx | 2 +- apps/web/src/shell/docks.tsx | 16 - apps/web/src/skill-version-author.ts | 8 - 43 files changed, 16 insertions(+), 2276 deletions(-) delete mode 100644 apps/web/src/bench/index.ts delete mode 100644 apps/web/src/bench/membership.ts delete mode 100644 apps/web/src/bench/tenancy-contracts.ts delete mode 100644 apps/web/src/chat-artifact-open.ts delete mode 100644 apps/web/src/chat/agent-part-adapter.ts delete mode 100644 apps/web/src/chat/blocks/connect-service-actions.ts delete mode 100644 apps/web/src/chat/blocks/connect-service-block-container.tsx delete mode 100644 apps/web/src/chat/blocks/connect-service-block.tsx delete mode 100644 apps/web/src/chat/blocks/metrics-block.tsx delete mode 100644 apps/web/src/chat/blocks/registry.tsx delete mode 100644 apps/web/src/chat/blocks/steps-block.tsx delete mode 100644 apps/web/src/chat/blocks/stream-block.tsx delete mode 100644 apps/web/src/chat/clock.ts delete mode 100644 apps/web/src/chat/failed-turn-models.ts delete mode 100644 apps/web/src/chat/relative-time.ts delete mode 100644 apps/web/src/chat/resume-failed-banner.tsx delete mode 100644 apps/web/src/chat/wire/agent-address.ts delete mode 100644 apps/web/src/chat/wire/consumer-inference-text.ts delete mode 100644 apps/web/src/chat/wire/participants.ts delete mode 100644 apps/web/src/chat/wire/parts.ts delete mode 100644 apps/web/src/chat/wire/stream-events.ts delete mode 100644 apps/web/src/chat/wire/tools-unsupported.ts delete mode 100644 apps/web/src/insights-timeline.ts delete mode 100644 apps/web/src/lib/text-diff/index.ts delete mode 100644 apps/web/src/lib/text-diff/line-diff.test.ts delete mode 100644 apps/web/src/lib/text-diff/line-diff.ts delete mode 100644 apps/web/src/pages/diff-view.css delete mode 100644 apps/web/src/pages/diff-view.tsx delete mode 100644 apps/web/src/react-ui-toast-mock.ts delete mode 100644 apps/web/src/settings/notifications-section.tsx delete mode 100644 apps/web/src/shell/docks.tsx delete mode 100644 apps/web/src/skill-version-author.ts diff --git a/apps/web/src/approval-actions.ts b/apps/web/src/approval-actions.ts index 190cef1906..82dba3bbce 100644 --- a/apps/web/src/approval-actions.ts +++ b/apps/web/src/approval-actions.ts @@ -20,17 +20,6 @@ export function createChatApprovalActions( void queryClient.invalidateQueries({ queryKey: tenantKeys.pendingApprovals(tenantId), }); - // The tasks backend still owns these inbox routes even though the - // Inbox page that used to read them is gone. - void queryClient.invalidateQueries({ - predicate: (query) => { - const key = query.queryKey; - if (!Array.isArray(key)) return false; - return key.some( - (part) => typeof part === "string" && part.includes(`/api/tenants/${tenantId}/inbox`), - ); - }, - }); } // A 409 (already resolved, run no longer running) is kept distinct from diff --git a/apps/web/src/bench/index.ts b/apps/web/src/bench/index.ts deleted file mode 100644 index c5dadfd909..0000000000 --- a/apps/web/src/bench/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -export { isRawIdentifier } from "./membership"; - -export { - INTERCHANGE_ROLES, - WorkbenchIcon, - DmWorkbenchFlag, - dmWorkbenchName, - createDmWorkbenchSpec, - validateParentId, - wouldCreateParentCycle, - isInterchangeRole, - canShareWorkbenchWithinParent, -} from "./tenancy-contracts"; -export type { - InterchangeRole, - TenantParentLookup, - ParentValidationResult, -} from "./tenancy-contracts"; diff --git a/apps/web/src/bench/membership.ts b/apps/web/src/bench/membership.ts deleted file mode 100644 index 7c8210f3c8..0000000000 --- a/apps/web/src/bench/membership.ts +++ /dev/null @@ -1,13 +0,0 @@ -// Raw platform-id detection for display decisions. Display-name and slug -// helpers left with the deleted switcher / member list. - -/** Every platform id prefix this UI must never render verbatim. Mirrors the - * same floor `packages/chat-ui` enforces over its own fixture surface. */ -const RAW_ID_PATTERN = /\b(prn_|ins_|tnt_|role_|grant_)[a-z0-9]/i; - -/** True when `name` is (or contains) a raw platform id rather than a - * human-assigned one — the shape a tenant falls back to server-side when - * nothing ever named it. */ -export function isRawIdentifier(name: string): boolean { - return RAW_ID_PATTERN.test(name); -} diff --git a/apps/web/src/bench/tenancy-contracts.ts b/apps/web/src/bench/tenancy-contracts.ts deleted file mode 100644 index 6076dea000..0000000000 --- a/apps/web/src/bench/tenancy-contracts.ts +++ /dev/null @@ -1,120 +0,0 @@ -// Pure tenancy contracts workbench owns on top of Interchange's native -// tenant hierarchy and roles. No parallel role table, no copied -// inheritance — see docs/TENANCY.md. - -import { type } from "arktype"; - -/** Interchange native roles — mirror 1:1; never invent parallels. */ -export const INTERCHANGE_ROLES = ["owner", "admin", "member"] as const; -export type InterchangeRole = (typeof INTERCHANGE_ROLES)[number]; - -export const WorkbenchIcon = type({ - /** 1–2 character monogram shown in the switcher and badges. */ - monogram: type("string >= 1").pipe((s) => s.slice(0, 2)), - /** CSS color token or hex; validated loosely at the boundary. */ - color: type("string >= 1"), -}); -export type WorkbenchIcon = typeof WorkbenchIcon.infer; - -export const DmWorkbenchFlag = type({ - dm: "true", - memberUserIds: type("string").array().atLeastLength(2).atMostLength(2), -}); -export type DmWorkbenchFlag = typeof DmWorkbenchFlag.infer; - -// Never invents a third-party name — caller supplies the resolved label. -export function dmWorkbenchName(counterpartyDisplayName: string): string { - const trimmed = counterpartyDisplayName.trim(); - return trimmed.length > 0 ? trimmed : "Direct message"; -} - -// The workbench runtime still lives in the owning tenant; this is the -// product shape the create path must honor. -export function createDmWorkbenchSpec(args: { - readonly counterpartyDisplayName: string; - readonly memberUserIds: readonly [string, string]; -}): { - readonly name: string; - readonly dm: true; - readonly memberUserIds: readonly [string, string]; -} { - const [a, b] = args.memberUserIds; - if (a === b) { - throw new Error("DM members must be two distinct user ids"); - } - return { - name: dmWorkbenchName(args.counterpartyDisplayName), - dm: true, - memberUserIds: [a, b], - }; -} - -export type TenantParentLookup = { - /** Returns the parent id of a tenant, or null if root / unknown. */ - getParentId(tenantId: string): Promise; - /** True when the tenant row exists. */ - exists(tenantId: string): Promise; -}; - -export type ParentValidationResult = - | { readonly ok: true } - | { readonly ok: false; readonly code: string; readonly message: string }; - -// Cycle-safe for create (new id doesn't exist yet). Does not grant-check -// — caller must verify owner separately. -export async function validateParentId( - parentId: string | null | undefined, - lookup: TenantParentLookup, -): Promise { - if (parentId === null || parentId === undefined || parentId === "") { - return { ok: true }; - } - if (!(await lookup.exists(parentId))) { - return { - ok: false, - code: "unknown_parent", - message: "parent tenant does not exist", - }; - } - return { ok: true }; -} - -// For any future reparent path; create paths pass a not-yet-existing -// childId and always pass. -export async function wouldCreateParentCycle( - childId: string, - newParentId: string, - lookup: TenantParentLookup, -): Promise { - if (childId === newParentId) return true; - let current: string | null = newParentId; - const seen = new Set(); - while (current !== null) { - if (current === childId) return true; - if (seen.has(current)) return true; // existing cycle in store - seen.add(current); - current = await lookup.getParentId(current); - } - return false; -} - -// Rejects invented roles so UI never drifts from Interchange. -export function isInterchangeRole(value: string): value is InterchangeRole { - return (INTERCHANGE_ROLES as readonly string[]).includes(value); -} - -// Both tenants must share a parent (or one is the parent of the other). -// External cross-org is out of scope. -export async function canShareWorkbenchWithinParent( - tenantA: string, - tenantB: string, - lookup: TenantParentLookup, -): Promise { - if (tenantA === tenantB) return true; - const parentA = await lookup.getParentId(tenantA); - const parentB = await lookup.getParentId(tenantB); - if (parentA !== null && parentA === tenantB) return true; - if (parentB !== null && parentB === tenantA) return true; - if (parentA !== null && parentB !== null && parentA === parentB) return true; - return false; -} diff --git a/apps/web/src/chat-artifact-open.ts b/apps/web/src/chat-artifact-open.ts deleted file mode 100644 index e9d7dfdd1e..0000000000 --- a/apps/web/src/chat-artifact-open.ts +++ /dev/null @@ -1,89 +0,0 @@ -// A part with an `artifactId` resolves through the Library artifacts -// surface, never raw blob bytes; only a plain human upload with no -// Library row falls back to the blob route. - -import { - isTextDecodableMediaType, - resolveArtifactRendererKind, - resolveRendererKindFromMediaType, -} from "@/library"; -import { artifactPreviewPath, type ArtifactDetail } from "./api"; -import { base64ToUtf8 } from "./chat/threads-api"; -import type { CanvasArtifactContent } from "./shell/canvas-availability"; - -/** Same renderer-kind resolution `LibraryPage`'s preview pane uses, so a - * chip opened from chat renders identically to Library. */ -export function artifactContentFromDetail( - tenantId: string, - detail: ArtifactDetail, -): CanvasArtifactContent { - const rendererKind = resolveArtifactRendererKind(detail); - return { - id: detail.id, - title: detail.title, - rendererKind, - content: detail.content, - // Text-kind Library artifacts are co-editable (phase 2); the - // presence `/update` route's own write-grant check is the real gate - // — this only decides which pane a capable viewer sees. - canEdit: rendererKind === "doc", - ...(rendererKind === "html" ? { previewSrc: artifactPreviewPath(tenantId, detail.id) } : {}), - }; -} - -/** The honest "couldn't read it" pane for an artifact detail fetch that - * failed (network error, artifact no longer resolvable, etc). */ -export function artifactContentFromDetailError( - part: { readonly name: string }, - artifactId: string, - message: string, -): CanvasArtifactContent { - return { - id: artifactId, - title: part.name, - rendererKind: "unsupported", - content: "", - unavailableReason: message, - }; -} - -/** Binary content this UI can't decode as text renders through the - * "unsupported" pane with an honest reason, rather than raw bytes. */ -export function artifactContentFromBlob( - part: { readonly name: string; readonly mediaType: string }, - blobId: string, - contentBase64: string, -): CanvasArtifactContent { - const rendererKind = resolveRendererKindFromMediaType(part.mediaType, part.name); - if (!isTextDecodableMediaType(part.mediaType)) { - return { - id: blobId, - title: part.name, - rendererKind: "unsupported", - content: "", - unavailableReason: `"${part.mediaType}" isn't a text type this canvas can preview inline yet.`, - }; - } - return { - id: blobId, - title: part.name, - rendererKind, - content: base64ToUtf8(contentBase64), - }; -} - -/** The honest "couldn't read it" pane for a blob fetch that failed - * (network error, blob no longer resolvable, etc). */ -export function artifactContentFromBlobError( - part: { readonly name: string }, - blobId: string, - message: string, -): CanvasArtifactContent { - return { - id: blobId, - title: part.name, - rendererKind: "unsupported", - content: "", - unavailableReason: message, - }; -} diff --git a/apps/web/src/chat/agent-part-adapter.ts b/apps/web/src/chat/agent-part-adapter.ts deleted file mode 100644 index becdd4ec5a..0000000000 --- a/apps/web/src/chat/agent-part-adapter.ts +++ /dev/null @@ -1,10 +0,0 @@ -// Only reasoning crosses this boundary. - -import type { PartReasoning } from "@corbits/react-ui"; -import type { ReasoningPart } from "./wire/parts"; - -/** chat's reasoning carries no duration; react-ui's is optional, so it - * stays absent rather than being fabricated. */ -export function toReactUiReasoning(part: ReasoningPart): PartReasoning { - return { kind: "reasoning", text: part.text }; -} diff --git a/apps/web/src/chat/blocks/approval-actions.ts b/apps/web/src/chat/blocks/approval-actions.ts index 535cbc1a92..67f6f9197e 100644 --- a/apps/web/src/chat/blocks/approval-actions.ts +++ b/apps/web/src/chat/blocks/approval-actions.ts @@ -1,5 +1,3 @@ -// See docs/chat-wire-contract.md for why this is a host-supplied port. - // Never invented here — mirrors `ApprovalResponse.status`. export type ApprovalLiveStatus = "pending" | "approved" | "rejected" | "timeout" | "expired"; @@ -22,8 +20,9 @@ export type PlatformApprovalDetail = { readonly argumentsSummary?: string; }; -// See docs/chat-wire-contract.md for why `forbidden` is distinct from -// `canAct: false`. +// `forbidden` is distinct from `canAct: false`: the host's own status read +// was refused, so the card shows Approve/Deny anyway and lets the decision +// call's own authorization decide. export type ApprovalStatusQuery = | { readonly kind: "loading" } | { diff --git a/apps/web/src/chat/blocks/approve-block.tsx b/apps/web/src/chat/blocks/approve-block.tsx index a05d8571ff..59de5760b3 100644 --- a/apps/web/src/chat/blocks/approve-block.tsx +++ b/apps/web/src/chat/blocks/approve-block.tsx @@ -1,6 +1,5 @@ -// See docs/chat-wire-contract.md for why the platform's own detail is -// authoritative. No `ApprovalActions` port: falls back to pre-round-trip -// framing, fixed disabled buttons, no fetch. +// The platform's own detail is authoritative. No `ApprovalActions` port: +// falls back to pre-round-trip framing, fixed disabled buttons, no fetch. import { Button } from "@corbits/react-ui"; import type { ApproveBlockData } from "../wire/blocks"; diff --git a/apps/web/src/chat/blocks/connect-service-actions.ts b/apps/web/src/chat/blocks/connect-service-actions.ts deleted file mode 100644 index 3df4b82da3..0000000000 --- a/apps/web/src/chat/blocks/connect-service-actions.ts +++ /dev/null @@ -1,35 +0,0 @@ -// Every live fact — connected or not, which auth mode applies — is -// resolved by the host, never authored by the agent. - -export type ConnectAffordance = "oauth" | "keyless" | "api-key"; - -export type ConnectServiceQuery = - | { readonly kind: "loading" } - | { - readonly kind: "disconnected"; - readonly affordance: ConnectAffordance; - /** Where the key on the key-paste arm comes from, when the host - * knows — the descriptor's docs page. */ - readonly docsUrl?: string; - } - | { readonly kind: "connected" } - | { readonly kind: "error"; readonly message: string }; - -export type ConnectServiceResult = - | { readonly ok: true } - | { readonly ok: false; readonly message: string }; - -export interface ConnectServiceActions { - getConnectState(connectorId: string): Promise; - subscribeConnectState( - connectorId: string, - listener: (query: ConnectServiceQuery) => void, - ): () => void; - // Called on a `chat.settings` event so a mounted card flips without - // remounting. - notifySettingsChanged(): Promise; - /** One-click connect: starts the hosted OAuth hand-off (navigating - * away and back) or completes a keyless preset in place. */ - connect(connectorId: string): Promise; - submitKey(connectorId: string, key: string): Promise; -} diff --git a/apps/web/src/chat/blocks/connect-service-block-container.tsx b/apps/web/src/chat/blocks/connect-service-block-container.tsx deleted file mode 100644 index 615059144d..0000000000 --- a/apps/web/src/chat/blocks/connect-service-block-container.tsx +++ /dev/null @@ -1,63 +0,0 @@ -// Keyed by `connectorId`, not the message id, since the state is the -// tenant's, not the workbench's. No port: "no port, no feature" fallback. -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { useEffect } from "react"; -import type { ConnectServiceBlockData } from "../wire/blocks"; - -import type { ConnectServiceActions, ConnectServiceQuery } from "./connect-service-actions"; -import { ConnectServiceBlockView } from "./connect-service-block"; - -export function ConnectServiceBlockContainer({ - data, - actions, -}: { - readonly data: ConnectServiceBlockData; - readonly actions?: ConnectServiceActions; -}) { - const queryClient = useQueryClient(); - // Keyed by connector, not by message: the connection is the tenant's, so - // every card for the same service shares one read. - const queryKey = ["connect-state", data.connectorId] as const; - const live = useQuery({ - queryKey, - queryFn: () => - actions === undefined - ? Promise.resolve({ kind: "loading" }) - : actions.getConnectState(data.connectorId), - enabled: actions !== undefined, - }); - const query: ConnectServiceQuery = live.data ?? { kind: "loading" }; - - // The live fold is a subscription, so it writes into the cache the read - // above already owns rather than keeping a second copy beside it. - useEffect(() => { - if (actions === undefined) return; - return actions.subscribeConnectState(data.connectorId, (result) => { - queryClient.setQueryData(queryKey, result); - }); - // eslint-disable-next-line react-hooks/exhaustive-deps -- queryKey is derived from connectorId - }, [actions, data.connectorId, queryClient]); - - if (query.kind === "connected") { - return ; - } - - const affordance = query.kind === "disconnected" ? query.affordance : "oauth"; - return ( - void actions?.connect(data.connectorId)} - onSubmitKey={(key) => - actions !== undefined - ? actions.submitKey(data.connectorId, key) - : Promise.resolve({ ok: false, message: "Not available." }) - } - /> - ); -} diff --git a/apps/web/src/chat/blocks/connect-service-block.tsx b/apps/web/src/chat/blocks/connect-service-block.tsx deleted file mode 100644 index 47c7636e87..0000000000 --- a/apps/web/src/chat/blocks/connect-service-block.tsx +++ /dev/null @@ -1,197 +0,0 @@ -// Pure and props-driven; `./connect-service-block-container.tsx` wires -// the callbacks to a live port. - -import { useState } from "react"; -import { Button, Input } from "@corbits/react-ui"; -import { Check } from "@/lib/icons"; - -import { CHAT_STRINGS } from "../strings"; -import type { ConnectAffordance, ConnectServiceResult } from "./connect-service-actions"; -import { BlockCard } from "./block-card"; - -export type ConnectServiceCardProps = - | { - readonly kind: "disconnected"; - readonly displayName: string; - /** The agent's consumer-language framing for why connecting helps - * ("Connect Gmail so I can send this for you."). */ - readonly reason: string; - readonly affordance: ConnectAffordance; - readonly docsUrl?: string; - readonly onConnect: () => void; - readonly onSubmitKey: (key: string) => Promise; - } - | { - readonly kind: "connected"; - readonly displayName: string; - }; - -function KeyPasteBody({ - displayName, - reason, - docsUrl, - onSubmitKey, -}: { - readonly displayName: string; - readonly reason: string; - readonly docsUrl?: string; - readonly onSubmitKey: (key: string) => Promise; -}) { - const [fieldOpen, setFieldOpen] = useState(false); - const [key, setKey] = useState(""); - const [submitting, setSubmitting] = useState(false); - const [error, setError] = useState(undefined); - - async function submit() { - if (key.trim() === "" || submitting) return; - setSubmitting(true); - setError(undefined); - const result = await onSubmitKey(key.trim()); - setSubmitting(false); - if (!result.ok) { - setError(result.message); - return; - } - setKey(""); - setFieldOpen(false); - } - - if (!fieldOpen) { - return ( - <> -

{reason}

-
- -
-

- {CHAT_STRINGS.blockConnectServiceKeyHelper(displayName)} -

- - ); - } - - return ( - <> -

{reason}

- - { - setKey(event.target.value); - }} - disabled={submitting} - /> - {docsUrl !== undefined ? ( -

- - {CHAT_STRINGS.blockConnectServiceKeyWhere(displayName)} - -

- ) : null} - {error !== undefined ? ( -

{error}

- ) : null} -
- - -
- - ); -} - -function OneClickBody({ - displayName, - reason, - affordance, - onConnect, -}: { - readonly displayName: string; - readonly reason: string; - readonly affordance: "oauth" | "keyless"; - readonly onConnect: () => void; -}) { - return ( - <> -

{reason}

-
- -
-

- {affordance === "oauth" - ? CHAT_STRINGS.blockConnectServiceOAuthHelper(displayName) - : CHAT_STRINGS.blockConnectServiceKeylessHelper} -

- - ); -} - -export function ConnectServiceBlockView(props: ConnectServiceCardProps) { - if (props.kind === "connected") { - return ( - -

- - {CHAT_STRINGS.blockConnectServiceConnected(props.displayName)} -

-
- ); - } - return ( - - {props.affordance === "api-key" ? ( - - ) : ( - - )} - - ); -} diff --git a/apps/web/src/chat/blocks/metrics-block.tsx b/apps/web/src/chat/blocks/metrics-block.tsx deleted file mode 100644 index d61f6da90d..0000000000 --- a/apps/web/src/chat/blocks/metrics-block.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import type { MetricsBlockData } from "../wire/blocks"; - -import { BlockCard } from "./block-card"; - -export function MetricsBlockView({ data }: { readonly data: MetricsBlockData }) { - return ( - -
- {data.metrics.map((metric, index) => ( -
-
{metric.label}
-
{metric.value}
- {metric.detail !== undefined && ( -
- {metric.detail} -
- )} -
- ))} -
- {data.bars !== undefined && ( -
- {data.bars.map((bar, index) => ( -
- {bar.label} -
- ))} -
- )} -
- ); -} diff --git a/apps/web/src/chat/blocks/registry.tsx b/apps/web/src/chat/blocks/registry.tsx deleted file mode 100644 index 2f3a38f076..0000000000 --- a/apps/web/src/chat/blocks/registry.tsx +++ /dev/null @@ -1,74 +0,0 @@ -// Closed and curated: agents author data, this client owns the code. -// Unknown or malformed renders a labeled fallback, never raw JSON. - -import type { Block } from "../wire/blocks"; -import { parseBlock } from "../wire/blocks"; -import type { BlockPart } from "../wire/parts"; -import type { ReactElement } from "react"; - -import { CHAT_STRINGS } from "../strings"; -import { ApproveBlockView } from "./approve-block"; -import type { ApprovalActions } from "./approval-actions"; -import { ConnectServiceBlockContainer } from "./connect-service-block-container"; -import type { ConnectServiceActions } from "./connect-service-actions"; -import { MetricsBlockView } from "./metrics-block"; -import { StepsBlockView } from "./steps-block"; -import { StreamBlockView } from "./stream-block"; - -function renderKnownBlock( - block: Block, - approvalActions: ApprovalActions | undefined, - connectServiceActions: ConnectServiceActions | undefined, -): ReactElement { - switch (block.type) { - case "approve": - return ( - - ); - case "steps": - return ; - case "metrics": - return ; - case "stream": - return ; - case "connect-service": - return ( - - ); - } -} - -function UnsupportedBlock({ type }: { readonly type: string }) { - return ( -
- {CHAT_STRINGS.blockUnsupportedTitle} - {CHAT_STRINGS.blockUnsupportedBody(type)} -
- ); -} - -export function BlockPartView({ - block, - approvalActions, - connectServiceActions, -}: { - readonly block: BlockPart["block"]; - /** Host-supplied approve/deny round-trip; only the "approve" block reads - * it. Absent means the pre-round-trip fixed-disabled framing. */ - readonly approvalActions?: ApprovalActions; - /** Host-supplied generic connect round-trip; only the "connect-service" - * block reads it. Absent means the pre-round-trip disconnected framing. */ - readonly connectServiceActions?: ConnectServiceActions; -}) { - const result = parseBlock(block); - if (!result.ok) { - return ; - } - return renderKnownBlock(result.block, approvalActions, connectServiceActions); -} diff --git a/apps/web/src/chat/blocks/steps-block.tsx b/apps/web/src/chat/blocks/steps-block.tsx deleted file mode 100644 index 514f697b4a..0000000000 --- a/apps/web/src/chat/blocks/steps-block.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import type { StepsBlockData } from "../wire/blocks"; - -import { BlockCard } from "./block-card"; - -export function StepsBlockView({ data }: { readonly data: StepsBlockData }) { - return ( - -
- {data.steps.map((step, index) => ( -
-
- ))} -
-
- ); -} diff --git a/apps/web/src/chat/blocks/stream-block.tsx b/apps/web/src/chat/blocks/stream-block.tsx deleted file mode 100644 index 5d5ff2a235..0000000000 --- a/apps/web/src/chat/blocks/stream-block.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import type { StreamBlockData } from "../wire/blocks"; - -import { BlockCard } from "./block-card"; - -export function StreamBlockView({ data }: { readonly data: StreamBlockData }) { - return ( - -
-        {data.text}
-        {!data.done && 
-
- ); -} diff --git a/apps/web/src/chat/clock.ts b/apps/web/src/chat/clock.ts deleted file mode 100644 index f076335ac2..0000000000 --- a/apps/web/src/chat/clock.ts +++ /dev/null @@ -1,13 +0,0 @@ -// A test seam so a fake clock can advance synchronously instead of -// sleeping — real-timer waits on these backstops flaked under CI. -export type Clock = { - readonly now: () => number; - readonly setTimeout: (callback: () => void, ms: number) => unknown; - readonly clearTimeout: (handle: unknown) => void; -}; - -export const REAL_CLOCK: Clock = { - now: () => Date.now(), - setTimeout: (callback, ms) => setTimeout(callback, ms), - clearTimeout: (handle) => clearTimeout(handle as ReturnType), -}; diff --git a/apps/web/src/chat/failed-turn-models.ts b/apps/web/src/chat/failed-turn-models.ts deleted file mode 100644 index 79b239e9fa..0000000000 --- a/apps/web/src/chat/failed-turn-models.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { chatCapableModels, providerDisplayName } from "@/settings/inference"; -import type { ModelInfo } from "@/settings/inference"; - -import { CHAT_STRINGS } from "./strings"; - -export const FAILED_TURN_MODEL_PICKER_LIMIT = 4; - -export type FailedTurnModelChoice = { - readonly canonicalName: string; - readonly label: string; -}; - -function offeringSupportsTools(capabilities: readonly string[]): boolean { - return capabilities.some((capability) => capability.startsWith("function-calling")); -} - -function toolCapableModels(models: readonly ModelInfo[]): readonly ModelInfo[] { - const kept: ModelInfo[] = []; - for (const model of chatCapableModels(models)) { - const offerings = model.offerings.filter((offering) => - offeringSupportsTools(offering.capabilities), - ); - if (offerings.length === 0) continue; - kept.push( - offerings.length === model.offerings.length ? model : { ...model, offerings: [...offerings] }, - ); - } - return kept; -} - -function toChoices(models: readonly ModelInfo[], limit: number): readonly FailedTurnModelChoice[] { - return models - .filter((model) => model.offerings.length > 0) - .slice(0, limit) - .map((model) => { - const topOffering = model.offerings[0]; - return { - canonicalName: model.canonicalName, - label: CHAT_STRINGS.workbenchSettingsAgentDetailModelOption( - model.displayName ?? model.canonicalName, - topOffering === undefined ? "" : providerDisplayName(topOffering.providerName), - ), - }; - }); -} - -// Capped so the strip stays a quiet inline row. -export function failedTurnModelChoices( - models: readonly ModelInfo[], - limit = FAILED_TURN_MODEL_PICKER_LIMIT, -): readonly FailedTurnModelChoice[] { - return toChoices(chatCapableModels(models), limit); -} - -// The recovery set when the failure was that the model can't use tools. -export function failedTurnToolCapableModelChoices( - models: readonly ModelInfo[], - limit = FAILED_TURN_MODEL_PICKER_LIMIT, -): readonly FailedTurnModelChoice[] { - return toChoices(toolCapableModels(models), limit); -} diff --git a/apps/web/src/chat/index.ts b/apps/web/src/chat/index.ts index 2e0e855efd..a71b80f007 100644 --- a/apps/web/src/chat/index.ts +++ b/apps/web/src/chat/index.ts @@ -1,44 +1,13 @@ export { WorkbenchLoadingState } from "./loading-state"; export { NoUsableModelBanner } from "./no-usable-model-banner"; -export { - CorbitAvatar, - CORBIT_DEFAULT_COLOR, - AVATAR_COLORS, - avatarColorClass, - avatarColorForPrincipal, - avatarClassForPrincipal, - resolveAvatarFill, -} from "./avatar"; -export type { AvatarFill, AvatarColor, CorbitAvatarProps, CorbitAvatarSize } from "./avatar"; +export { resolveAvatarFill } from "./avatar"; export { CHAT_STRINGS } from "./strings"; -export { BlockPartView } from "./blocks/registry"; -export { BlockCard } from "./blocks/block-card"; -export type { - ApprovalActions, - ApprovalLiveStatus, - ApprovalStatusQuery, - ApprovalDecisionResult, - PlatformApprovalDetail, -} from "./blocks/approval-actions"; -export type { - ConnectServiceActions, - ConnectServiceQuery, - ConnectServiceResult, - ConnectAffordance, -} from "./blocks/connect-service-actions"; +export type { ApprovalActions, ApprovalDecisionResult } from "./blocks/approval-actions"; -export { Markdown } from "./markdown"; - -export { - listWorkbenchTenants, - listWorkbenches, - workbenchesQueryKey, - workbenchesQueryKeyPrefix, -} from "./workbench-tenants"; export type { Workbench } from "./workbench-tenants"; export { profileSubjectFromParticipant } from "./profile-subject"; -export type { ProfileSubject, ProfileParticipant } from "./profile-subject"; +export type { ProfileSubject } from "./profile-subject"; diff --git a/apps/web/src/chat/inference-failure.ts b/apps/web/src/chat/inference-failure.ts index 1bc0708c0c..a6c5465087 100644 --- a/apps/web/src/chat/inference-failure.ts +++ b/apps/web/src/chat/inference-failure.ts @@ -1,15 +1,9 @@ -// Reply-prose match against classified failures (see -// docs/chat-wire-contract.md for why this can't be a structured read). +// Reply-prose match against classified failures. export const CLASSIFIED_INFERENCE_FAILURE_PREAMBLES: readonly string[] = [ "This agent could not complete your request due to a credential error", "This agent could not complete your request because the API quota has been exhausted", ]; -export { - CONSUMER_INFERENCE_FAILURE_NOTICE, - consumerFacingInferenceText, -} from "./wire/consumer-inference-text"; - export function isClassifiedInferenceFailureText(text: string): boolean { return CLASSIFIED_INFERENCE_FAILURE_PREAMBLES.some((preamble) => text.startsWith(preamble)); } diff --git a/apps/web/src/chat/relative-time.ts b/apps/web/src/chat/relative-time.ts deleted file mode 100644 index cee1257bac..0000000000 --- a/apps/web/src/chat/relative-time.ts +++ /dev/null @@ -1,15 +0,0 @@ -// An absent or unparseable timestamp renders as nothing, never a -// placeholder date. -export function formatRelativeActivity(iso: string | null): string { - if (iso === null) return ""; - const date = new Date(iso); - if (Number.isNaN(date.getTime())) return ""; - const deltaMs = Date.now() - date.getTime(); - const minutes = Math.round(deltaMs / 60_000); - if (minutes < 1) return "just now"; - if (minutes < 60) return `${minutes}m ago`; - const hours = Math.round(minutes / 60); - if (hours < 24) return `${hours}h ago`; - const days = Math.round(hours / 24); - return `${days}d ago`; -} diff --git a/apps/web/src/chat/resume-failed-banner.tsx b/apps/web/src/chat/resume-failed-banner.tsx deleted file mode 100644 index e699b275bb..0000000000 --- a/apps/web/src/chat/resume-failed-banner.tsx +++ /dev/null @@ -1,25 +0,0 @@ -// A failed catch-up fetch must not leave the workbench looking idle — -// never a silent `.catch(() => undefined)`. -import { Button } from "@corbits/react-ui"; -import { WarningCircle } from "@/lib/icons"; -import { CHAT_STRINGS } from "./strings"; - -export function ResumeFailedBanner({ - refId, - onRetry, -}: { - readonly refId: string; - readonly onRetry: () => void; -}) { - return ( -
-
- ); -} diff --git a/apps/web/src/chat/wire/agent-address.ts b/apps/web/src/chat/wire/agent-address.ts deleted file mode 100644 index c5534cca33..0000000000 --- a/apps/web/src/chat/wire/agent-address.ts +++ /dev/null @@ -1,14 +0,0 @@ -// Browser-side wire contract (see docs/chat-wire-contract.md). - -// Deliberately permissive, unlike `@intx/types`'s `parseAgentAddress`: the -// mention rule only needs the substring before "@", not a validated id. - -export function localPartOf(address: string): string { - const at = address.indexOf("@"); - return at === -1 ? address : address.slice(0, at); -} - -export function domainOf(address: string): string | undefined { - const at = address.indexOf("@"); - return at === -1 ? undefined : address.slice(at + 1); -} diff --git a/apps/web/src/chat/wire/blocks.ts b/apps/web/src/chat/wire/blocks.ts index 40d0c6e9a8..be740a52e9 100644 --- a/apps/web/src/chat/wire/blocks.ts +++ b/apps/web/src/chat/wire/blocks.ts @@ -1,12 +1,9 @@ -// Browser-side wire contract (see docs/chat-wire-contract.md) — apps/web -// must not import server-only packages. +// Browser-side wire contract — apps/web must not import server-only packages. import { type } from "arktype"; -import type { BlockPart } from "./parts"; - -// Every schema strips undeclared keys deeply so agent-authored extras (a -// hostile `tally` on a poll) never reach a renderer that trusts them. +// Strips undeclared keys deeply so agent-authored extras never reach a +// renderer that trusts them. // No action labels or resolved state: those live on the approval record, // never in the message, so an agent can't spoof a decision. @@ -18,109 +15,3 @@ export const ApproveBlockData = type({ "body?": "string", }).onDeepUndeclaredKey("delete"); export type ApproveBlockData = typeof ApproveBlockData.infer; - -export const StepsBlockData = type({ - title: "string", - steps: type({ - label: "string", - state: "'queued' | 'running' | 'done' | 'error'", - "note?": "string", - }).array(), -}).onDeepUndeclaredKey("delete"); -export type StepsBlockData = typeof StepsBlockData.infer; - -export const MetricsBlockData = type({ - title: "string", - metrics: type({ - label: "string", - value: "string", - "detail?": "string", - // Coloring a delta is a semantic, not a string: without a declared - // trend the detail renders neutral, so "+40% error rate" can't paint - // itself success-green. - "trend?": "'up' | 'down'", - }).array(), - "bars?": type({ - label: "string", - percent: "0 <= number <= 100", - }).array(), -}).onDeepUndeclaredKey("delete"); -export type MetricsBlockData = typeof MetricsBlockData.infer; - -export const StreamBlockData = type({ - title: "string", - text: "string", - done: "boolean", -}).onDeepUndeclaredKey("delete"); -export type StreamBlockData = typeof StreamBlockData.infer; - -// No auth mode or connected/disconnected verdict: an agent that could author -// "connected" would be spoofing live state next to a live button. Real -// state and the connect affordance come from the host at render time. -export const ConnectServiceBlockData = type({ - connectorId: "string > 0", - displayName: "string > 0", - reason: "string > 0", -}).onDeepUndeclaredKey("delete"); -export type ConnectServiceBlockData = typeof ConnectServiceBlockData.infer; - -export type Block = - | { readonly type: "approve"; readonly data: ApproveBlockData } - | { readonly type: "steps"; readonly data: StepsBlockData } - | { readonly type: "metrics"; readonly data: MetricsBlockData } - | { readonly type: "stream"; readonly data: StreamBlockData } - | { - readonly type: "connect-service"; - readonly data: ConnectServiceBlockData; - }; - -export type BlockParseResult = - | { readonly ok: true; readonly block: Block } - | { readonly ok: false; readonly type: string; readonly summary: string }; - -// Never throws, so one malformed block can't take down a timeline. -export function parseBlock(envelope: BlockPart["block"]): BlockParseResult { - switch (envelope.type) { - case "approve": { - const data = ApproveBlockData(envelope.data); - if (data instanceof type.errors) { - return { ok: false, type: envelope.type, summary: data.summary }; - } - return { ok: true, block: { type: "approve", data } }; - } - case "steps": { - const data = StepsBlockData(envelope.data); - if (data instanceof type.errors) { - return { ok: false, type: envelope.type, summary: data.summary }; - } - return { ok: true, block: { type: "steps", data } }; - } - case "metrics": { - const data = MetricsBlockData(envelope.data); - if (data instanceof type.errors) { - return { ok: false, type: envelope.type, summary: data.summary }; - } - return { ok: true, block: { type: "metrics", data } }; - } - case "stream": { - const data = StreamBlockData(envelope.data); - if (data instanceof type.errors) { - return { ok: false, type: envelope.type, summary: data.summary }; - } - return { ok: true, block: { type: "stream", data } }; - } - case "connect-service": { - const data = ConnectServiceBlockData(envelope.data); - if (data instanceof type.errors) { - return { ok: false, type: envelope.type, summary: data.summary }; - } - return { ok: true, block: { type: "connect-service", data } }; - } - default: - return { - ok: false, - type: envelope.type, - summary: `unknown block type "${envelope.type}"`, - }; - } -} diff --git a/apps/web/src/chat/wire/consumer-inference-text.ts b/apps/web/src/chat/wire/consumer-inference-text.ts deleted file mode 100644 index 08e69f8388..0000000000 --- a/apps/web/src/chat/wire/consumer-inference-text.ts +++ /dev/null @@ -1,62 +0,0 @@ -// Browser-side wire contract (see docs/chat-wire-contract.md). - -// HTTP status, raw provider dumps, and JSON error objects never belong on -// the timeline — DESIGN.md Honesty is one consumer sentence. - -import { - TOOLS_UNSUPPORTED_CONSUMER_MESSAGE, - isToolsUnsupportedInferenceText, -} from "./tools-unsupported"; - -const HTTP_STATUS_MARK = /\[HTTP\s+\d+\]/i; -const TRAILING_HTTP_DUMP = /\s*\[HTTP\s+\d+\]:[\s\S]*$/i; - -export const CONSUMER_INFERENCE_FAILURE_NOTICE = - "This didn't go through. Try again, or check the connection in Settings."; - -// Byte-for-byte copies of `@intx/inference`'s preambles, kept here so the -// bench-list preview path can match them without depending on `@/chat`. -export const CLASSIFIED_INFERENCE_FAILURE_PREAMBLES: readonly string[] = [ - "This agent could not complete your request due to a credential error", - "This agent could not complete your request because the API quota has been exhausted", -]; - -function isProviderJsonDump(raw: string): boolean { - const trimmed = raw.trim(); - if (!(trimmed.startsWith("{") || trimmed.startsWith("["))) return false; - return /"error"/i.test(trimmed); -} - -function needsSanitization(raw: string): boolean { - return HTTP_STATUS_MARK.test(raw) || isProviderJsonDump(raw); -} - -/** Drop HTTP status, raw provider text, and classified failure preambles. */ -export function consumerFacingInferenceText(raw: string): string { - if (isToolsUnsupportedInferenceText(raw)) { - return TOOLS_UNSUPPORTED_CONSUMER_MESSAGE; - } - if (!needsSanitization(raw) && !isClassifiedInferenceFailureText(raw)) { - return raw; - } - const stripped = raw.replace(TRAILING_HTTP_DUMP, "").trim(); - if ( - stripped.length > 0 && - !needsSanitization(stripped) && - !isClassifiedInferenceFailureText(stripped) - ) { - return stripped; - } - return CONSUMER_INFERENCE_FAILURE_NOTICE; -} - -/** True when `text` is (or starts with) a classified inference-failure preamble. */ -export function isClassifiedInferenceFailureText(text: string): boolean { - return CLASSIFIED_INFERENCE_FAILURE_PREAMBLES.some((preamble) => text.startsWith(preamble)); -} - -// Never the full failure paragraph or raw provider dumps. -export function activityPreviewText(raw: string): string { - const facing = consumerFacingInferenceText(raw); - return facing; -} diff --git a/apps/web/src/chat/wire/display-name.ts b/apps/web/src/chat/wire/display-name.ts index 8715e18416..5543dac7ac 100644 --- a/apps/web/src/chat/wire/display-name.ts +++ b/apps/web/src/chat/wire/display-name.ts @@ -1,5 +1,3 @@ -// Browser-side wire contract (see docs/chat-wire-contract.md). - // One derivation for "what does this agent look like to a person", never a // scattered `description ?? name` reimplemented per call site. import { type } from "arktype"; diff --git a/apps/web/src/chat/wire/id-leak-guard.ts b/apps/web/src/chat/wire/id-leak-guard.ts index 37ec95bab8..25ef48d50c 100644 --- a/apps/web/src/chat/wire/id-leak-guard.ts +++ b/apps/web/src/chat/wire/id-leak-guard.ts @@ -1,5 +1,4 @@ -// Mirrors PREFIXES in vendor/intx/hub-common/src/ids.ts (see -// docs/chat-wire-contract.md, which also covers this file's rationale). +// Mirrors PREFIXES in vendor/intx/hub-common/src/ids.ts. // Matches `_`, space, or `-` as separator so both the raw id and // `humanizeSlug`'s Title-Cased reading of one ("Run 737a058d…") trip it. diff --git a/apps/web/src/chat/wire/mentions.ts b/apps/web/src/chat/wire/mentions.ts index c0178c2659..12a4c7de1c 100644 --- a/apps/web/src/chat/wire/mentions.ts +++ b/apps/web/src/chat/wire/mentions.ts @@ -1,37 +1,5 @@ -// Browser-side wire contract (see docs/chat-wire-contract.md). - -// The composer and the server fan-out both derive mentions from the same -// `ParticipantRecord[]`, so what the composer highlights is always exactly -// who gets a copy. - -import type { Part as PartType } from "./parts"; -import type { ParticipantRecord } from "./participants"; - // Humans (`prn_…`) read the workbench timeline directly and are never // fanned a copy; only agent addresses are. export function isAgentAddress(participant: string): boolean { return participant.includes("@") && !participant.startsWith("prn_"); } - -function escapeForRegExp(text: string): string { - return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - -// Structural match only (`@handle` at a word boundary) — not a full mention -// syntax parse. -export function mentionedParticipants( - parts: readonly PartType[], - participants: readonly ParticipantRecord[], -): string[] { - const texts = parts - .filter((part): part is Extract => part.kind === "text") - .map((part) => part.text); - if (texts.length === 0) return []; - return participants - .filter((participant) => isAgentAddress(participant.address)) - .filter((participant) => { - const mentionPattern = new RegExp(`@${escapeForRegExp(participant.handle)}\\b`); - return texts.some((text) => mentionPattern.test(text)); - }) - .map((participant) => participant.address); -} diff --git a/apps/web/src/chat/wire/participants.ts b/apps/web/src/chat/wire/participants.ts deleted file mode 100644 index 7c8608c79e..0000000000 --- a/apps/web/src/chat/wire/participants.ts +++ /dev/null @@ -1,87 +0,0 @@ -// Browser-side wire contract (see docs/chat-wire-contract.md). - -// Reading tolerates a bare address string (pre-rollout data) and upgrades it -// to a record; writing always produces records, never bare strings. - -import { type } from "arktype"; -import { localPartOf } from "./agent-address"; - -export interface ParticipantRecord { - readonly address: string; - readonly handle: string; -} - -const ParticipantRecordSchema = type({ - address: "string", - handle: "string", -}); - -/** The permissive shape a single `chat/participants` entry may carry on - * the wire: a bare address string, or an already-upgraded record. */ -export const ParticipantEntry = type("string").or(ParticipantRecordSchema); - -/** The full `chat/participants` setting value's permissive wire shape. */ -export const ParticipantsSetting = ParticipantEntry.array(); - -function parseParticipantEntry(entry: unknown): ParticipantRecord { - if (typeof entry === "string") { - return { address: entry, handle: localPartOf(entry) }; - } - const result = ParticipantRecordSchema(entry); - if (result instanceof type.errors) { - throw new Error(`invalid participant entry: ${result.summary}`); - } - return result; -} - -// Tolerant of a workbench with a mix of pre- and post-rollout entries. -export function parseParticipants(raw: unknown): ParticipantRecord[] { - if (!Array.isArray(raw)) return []; - return raw.map(parseParticipantEntry); -} - -// Falls back to the address's local part when the name yields nothing -// usable (e.g. all punctuation). -export function handleFromName(name: string, fallbackAddress: string): string { - const slug = name - .trim() - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, ""); - return slug.length > 0 ? slug : localPartOf(fallbackAddress); -} - -// "echo" becomes "echo-2", then "echo-3" — the first suffix not taken. -export function dedupeHandle(handle: string, taken: ReadonlySet): string { - if (!taken.has(handle)) return handle; - let suffix = 2; - while (taken.has(`${handle}-${suffix}`)) suffix += 1; - return `${handle}-${suffix}`; -} - -// Returns the existing list by identity on a same-address retry, so a -// caller can tell "already present" from "appended". -export function addParticipant( - existing: readonly ParticipantRecord[], - address: string, - desiredHandle: string, -): ParticipantRecord[] { - if (existing.some((participant) => participant.address === address)) { - return existing as ParticipantRecord[]; - } - const taken = new Set(existing.map((participant) => participant.handle)); - const handle = dedupeHandle(desiredHandle, taken); - return [...existing, { address, handle }]; -} - -// Returns the same array reference when the address names no participant, -// so a caller can tell "nothing changed" by identity. -export function removeParticipant( - existing: readonly ParticipantRecord[], - address: string, -): ParticipantRecord[] { - if (!existing.some((participant) => participant.address === address)) { - return existing as ParticipantRecord[]; - } - return existing.filter((participant) => participant.address !== address); -} diff --git a/apps/web/src/chat/wire/parts.ts b/apps/web/src/chat/wire/parts.ts deleted file mode 100644 index 8e57c710eb..0000000000 --- a/apps/web/src/chat/wire/parts.ts +++ /dev/null @@ -1,93 +0,0 @@ -// Browser-side wire contract (see docs/chat-wire-contract.md). - -import { type } from "arktype"; - -export const TextPart = type({ - kind: "'text'", - text: "string", - // Marks the undelivered-turn notice so it renders the failed-turn strip, - // not a plain bubble. - "turnFailed?": "boolean", - // Set with `turnFailed` for a missing/unresolvable model or one that - // can't use tools, to render named recovery instead of Retry. - "turnFailedReason?": "'model_unavailable' | 'tools_unsupported'", - // Marks a user-cancelled turn — distinct from `turnFailed` since - // cancelling isn't a failure and gets its own copy. - "turnCancelled?": "boolean", -}); -export type TextPart = typeof TextPart.infer; - -export const ReasoningPart = type({ - kind: "'reasoning'", - text: "string", -}); -export type ReasoningPart = typeof ReasoningPart.infer; - -export const ToolTracePart = type({ - kind: "'tool-trace'", - name: "string", - input: "unknown", - "output?": "unknown", - status: "'pending' | 'running' | 'success' | 'error'", -}); -export type ToolTracePart = typeof ToolTracePart.infer; - -export const BlockPart = type({ - kind: "'block'", - block: { - type: "string", - data: "unknown", - }, -}); -export type BlockPart = typeof BlockPart.infer; - -// Exactly one of `blobId` (already persisted) or `data` (inline base64) -// must be present, unless `artifactId` links this file to a Library row -// instead (see @corbits/artifacts). -export const FilePart = type({ - kind: "'file'", - name: "string", - mediaType: "string", - "blobId?": "string", - "data?": "string", - "artifactId?": "string", -}).narrow((part, ctx) => { - const hasBlobId = part.blobId !== undefined; - const hasData = part.data !== undefined; - // An artifact-backed file's bytes live in the Library row, so blobId/data - // stay optional (still mutually exclusive) once artifactId is set. - if (part.artifactId !== undefined) { - if (hasBlobId && hasData) { - return ctx.reject("`blobId` and `data` cannot both be set on a FilePart"); - } - return true; - } - if (hasBlobId === hasData) { - return ctx.reject("exactly one of `blobId` or `data` must be set on a FilePart"); - } - return true; -}); -export type FilePart = typeof FilePart.infer; - -export const EventPart = type({ - kind: "'event'", - event: "string", - data: "unknown", -}); -export type EventPart = typeof EventPart.infer; - -export const Part = TextPart.or(ReasoningPart) - .or(ToolTracePart) - .or(BlockPart) - .or(FilePart) - .or(EventPart); -export type Part = typeof Part.infer; - -// The only supported way to bring external JSON into the `Part` type. -export function parsePart(data: unknown): Part { - const result = Part(data); - if (result instanceof type.errors) { - throw new Error(`invalid message part: ${result.summary}`); - } - return result; -} diff --git a/apps/web/src/chat/wire/stream-events.ts b/apps/web/src/chat/wire/stream-events.ts deleted file mode 100644 index 594c85c6ba..0000000000 --- a/apps/web/src/chat/wire/stream-events.ts +++ /dev/null @@ -1,56 +0,0 @@ -// Browser-side wire contract (see docs/chat-wire-contract.md, which -// also covers this file's organizing rule). -import { type } from "arktype"; -import { Part } from "./parts"; - -const WorkbenchMessageSender = type({ - name: "string | null", - address: "string", -}); - -// Mail headers ride along only when the row was actually mailed; absent, -// never invented, otherwise. -export const ChatMessageEventData = type({ - id: "string", - workbenchId: "string", - ref: type({ kind: "'workbench'", id: "string" }), - createdAt: "string", - threadId: "string | null", - sender: WorkbenchMessageSender, - parts: Part.array(), - "messageId?": "string", - "inReplyTo?": "string", - "references?": "string[]", -}); -export type ChatMessageEventData = typeof ChatMessageEventData.infer; - -/** The full post-change settings object — never a diff a subscriber - * would have to fold against state it might not hold yet. */ -export const ChatSettingsEventData = type({ - updatedBy: "string", - settings: "Record", -}); -export type ChatSettingsEventData = typeof ChatSettingsEventData.infer; - -export const ChatTypingEventData = type({ - principalId: "string", -}); -export type ChatTypingEventData = typeof ChatTypingEventData.infer; - -// "offline" fires only once a principal's *last* open connection closes — -// a second open tab never fires it. -export const ChatPresenceEventData = type({ - principalId: "string", - state: "'online' | 'offline'", - lastActiveAt: "string", -}); -export type ChatPresenceEventData = typeof ChatPresenceEventData.infer; - -// Sent only to the connecting stream itself, never broadcast. -export const ChatPresenceSnapshotEventData = type({ - members: type({ - principalId: "string", - lastActiveAt: "string", - }).array(), -}); -export type ChatPresenceSnapshotEventData = typeof ChatPresenceSnapshotEventData.infer; diff --git a/apps/web/src/chat/wire/tools-unsupported.ts b/apps/web/src/chat/wire/tools-unsupported.ts deleted file mode 100644 index fbea8f2380..0000000000 --- a/apps/web/src/chat/wire/tools-unsupported.ts +++ /dev/null @@ -1,13 +0,0 @@ -// Browser-side wire contract (see docs/chat-wire-contract.md). - -export const TOOLS_UNSUPPORTED_CONSUMER_MESSAGE = "This agent's model can't use tools."; - -// Conservative on purpose: must not match an unrelated HTTP 400, an -// embedding model's "does not support generate", or connector prose like -// "The grep tool is not supported in this sandbox". -const TOOLS_UNSUPPORTED_RE = - /\b(?:tools|tool use)\b[^.]{0,80}\b(?:is |are |use is )?not supported\b|\b(?:is |are )?not supported\b[^.]{0,80}\b(?:tools\b|tool use)\b|\bdoes(?: not|n't) support (?:tools?|tool use|function[\s-]?calling)\b|\bfunction[\s-]?calling\b[^.]{0,80}\b(?:not supported|unsupported|required|missing)\b|\b(?:not supported|unsupported|required|missing)\b[^.]{0,80}\bfunction[\s-]?calling\b/i; - -export function isToolsUnsupportedInferenceText(raw: string): boolean { - return TOOLS_UNSUPPORTED_RE.test(raw); -} diff --git a/apps/web/src/insights-timeline.ts b/apps/web/src/insights-timeline.ts deleted file mode 100644 index 2a3f7c613a..0000000000 --- a/apps/web/src/insights-timeline.ts +++ /dev/null @@ -1,75 +0,0 @@ -// Lightweight day-bucket timeline over existing run timestamps — no new -// analytics backend. Buckets the last N UTC days (oldest first) so a small -// bar/sparkline can render honest volume from data the page already holds. - -import type { WorkflowRun } from "./api"; - -export type DayBucket = { - /** Calendar day as `YYYY-MM-DD` (UTC). */ - readonly key: string; - /** Short, single-line axis label for the day. */ - readonly label: string; - /** Purpose runs that started on this day. */ - readonly count: number; -}; - -/** Default window for the timeline strip. */ -export const INSIGHTS_TIMELINE_DAYS = 14; - -const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; - -// Days with no runs still appear as zero-count buckets so the strip -// never collapses. -export function bucketRunsByDay( - runs: readonly WorkflowRun[], - days: number = INSIGHTS_TIMELINE_DAYS, - now: Date = new Date(), -): readonly DayBucket[] { - // Start of the current UTC day, then walk back `days - 1` days so the - // window includes today. - const todayUTC = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()); - const dayMs = 86_400_000; - - const counts = new Map(); - const keys: string[] = []; - for (let offset = days - 1; offset >= 0; offset -= 1) { - const ms = todayUTC - offset * dayMs; - const d = new Date(ms); - const key = utcDayKey(d); - keys.push(key); - counts.set(key, 0); - } - - for (const run of runs) { - const key = isoToUtcDayKey(run.createdAt); - if (key === null) continue; - if (counts.has(key)) { - counts.set(key, (counts.get(key) ?? 0) + 1); - } - } - - return keys.map((key) => ({ - key, - label: labelForKey(key), - count: counts.get(key) ?? 0, - })); -} - -function utcDayKey(d: Date): string { - const y = d.getUTCFullYear(); - const m = String(d.getUTCMonth() + 1).padStart(2, "0"); - const day = String(d.getUTCDate()).padStart(2, "0"); - return `${y}-${m}-${day}`; -} - -function isoToUtcDayKey(iso: string): string | null { - const d = new Date(iso); - if (Number.isNaN(d.getTime())) return null; - return utcDayKey(d); -} - -function labelForKey(key: string): string { - // key is `YYYY-MM-DD`; derive a locale-independent `Mon D` label. - const [, month, day] = key.split("-").map(Number) as [number, number, number]; - return `${MONTHS[month - 1]} ${day}`; -} diff --git a/apps/web/src/lib/text-diff/index.ts b/apps/web/src/lib/text-diff/index.ts deleted file mode 100644 index ec7dd0e286..0000000000 --- a/apps/web/src/lib/text-diff/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -export { - DEFAULT_DIFF_LIMITS, - diffText, - diffTotals, - normalizeNewlines, - type Diff, - type DiffLimits, - type DiffLine, - type DiffLineKind, - type DiffTotals, -} from "./line-diff"; diff --git a/apps/web/src/lib/text-diff/line-diff.test.ts b/apps/web/src/lib/text-diff/line-diff.test.ts deleted file mode 100644 index 420a4ff3a2..0000000000 --- a/apps/web/src/lib/text-diff/line-diff.test.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { - DEFAULT_DIFF_LIMITS, - diffText, - normalizeNewlines, - type Diff, - type DiffLine, -} from "./line-diff"; - -function diffed(before: string, after: string, limits = DEFAULT_DIFF_LIMITS) { - const diff = diffText(before, after, limits); - if (diff.status !== "diffed") { - throw new Error(`expected a diff, got ${diff.status}`); - } - return diff; -} - -function render(before: string, after: string): string[] { - return diffed(before, after).lines.map((line: DiffLine) => { - if (line.kind === "skipped") return `~${line.text}`; - const marker = line.kind === "added" ? "+" : line.kind === "removed" ? "-" : " "; - return `${marker}${line.text}`; - }); -} - -function lines(count: number, prefix = "line"): string { - return Array.from({ length: count }, (_, i) => `${prefix} ${String(i)}`).join("\n"); -} - -describe("diffText", () => { - test("identical text is reported as identical, with no script to render", () => { - const diff: Diff = diffText("one\ntwo", "one\ntwo"); - expect(diff.status).toBe("identical"); - }); - - test("an edit in the middle leaves the surrounding lines as context", () => { - expect(render("one\ntwo\nthree", "one\nTWO\nthree")).toEqual([ - " one", - "-two", - "+TWO", - " three", - ]); - }); - - test("an inserted line is the only change", () => { - expect(render("one\nthree", "one\ntwo\nthree")).toEqual([" one", "+two", " three"]); - expect(diffed("one\nthree", "one\ntwo\nthree").totals).toEqual({ - added: 1, - removed: 0, - }); - }); - - test("a removed line is the only change", () => { - expect(render("one\ntwo\nthree", "one\nthree")).toEqual([" one", "-two", " three"]); - }); - - test("line numbers point at each side's own revision", () => { - expect( - diffed("a\nb", "a\nc\nb").lines.map((line) => [ - line.kind, - line.beforeLineNumber, - line.afterLineNumber, - ]), - ).toEqual([ - ["context", 1, 1], - ["added", null, 2], - ["context", 2, 3], - ]); - }); - - test("empty before is all additions and empty after is all removals", () => { - expect(render("", "one\ntwo")).toEqual(["+one", "+two"]); - expect(render("one\ntwo", "")).toEqual(["-one", "-two"]); - expect(diffText("", "").status).toBe("identical"); - }); - - test("a trailing blank line is a visible addition", () => { - expect(render("one", "one\n")).toEqual([" one", "+"]); - }); - - test("a rewritten block reads as its removals then its additions", () => { - expect(render("a\nb\nc\nd", "a\nx\ny\nd")).toEqual([" a", "-b", "-c", "+x", "+y", " d"]); - }); - - test("a moved line is not reported as unchanged in both places", () => { - const diff = diffed("header\nbody", "body\nheader"); - expect(diff.totals.added).toBeGreaterThan(0); - expect(diff.totals.removed).toBeGreaterThan(0); - expect(diff.lines.filter((line) => line.kind === "context")).toHaveLength(1); - }); -}); - -describe("newline conventions", () => { - test("normalizeNewlines collapses CRLF and lone carriage returns", () => { - expect(normalizeNewlines("a\r\nb\rc\nd")).toBe("a\nb\nc\nd"); - }); - - test("the same text in two newline conventions is identical, not rewritten", () => { - expect(diffText("one\r\ntwo", "one\ntwo").status).toBe("identical"); - expect(diffText("one\rtwo", "one\ntwo").status).toBe("identical"); - }); -}); - -describe("long documents", () => { - test("unchanged runs between edits collapse to a skipped row", () => { - const before = `head\n${lines(40)}\ntail`; - const after = `HEAD\n${lines(40)}\nTAIL`; - const diff = diffed(before, after); - const skipped = diff.lines.filter((line) => line.kind === "skipped"); - expect(skipped).toHaveLength(1); - expect(skipped[0]?.skippedLines).toBe(34); - expect(skipped[0]?.text).toBe("34 unchanged lines"); - // Both edits (two rows each), 3 context lines either side of the - // collapsed run, the skipped row itself, and nothing else. - expect(diff.lines).toHaveLength(2 + 3 + 1 + 3 + 2); - expect(diff.totals).toEqual({ added: 2, removed: 2 }); - }); - - test("a one-line edit in a 20k-line document diffs on trimmed input, fast", () => { - const body = lines(20_000); - const edited = body.replace("line 10000", "line 10000 — revised"); - const started = performance.now(); - const diff = diffed(body, edited); - const elapsedMs = performance.now() - started; - expect(diff.totals).toEqual({ added: 1, removed: 1 }); - // The quadratic walk sees one line per side; anything near the full - // document would blow far past this. - expect(elapsedMs).toBeLessThan(250); - }); - - test("a wholly rewritten large document is refused instead of allocating for it", () => { - const before = lines(6_000, "before"); - const after = lines(6_000, "after"); - const diff = diffText(before, after); - expect(diff).toEqual({ - status: "too-large", - beforeLines: 6_000, - afterLines: 6_000, - changedBeforeLines: 6_000, - changedAfterLines: 6_000, - }); - }); - - test("the character cap refuses long lines even when the line count is small", () => { - const before = `${"a".repeat(300_000)}\nx`; - const after = `${"b".repeat(300_000)}\ny`; - expect(diffText(before, after).status).toBe("too-large"); - }); - - test("the caps are the caller's to set", () => { - const tiny = { maxLines: 2, maxCharacters: 1_000, contextLines: 1 }; - expect(diffText("a\nb\nc", "x\ny\nz", tiny).status).toBe("too-large"); - expect(diffText("a\nb", "x\ny", tiny).status).toBe("diffed"); - }); -}); diff --git a/apps/web/src/lib/text-diff/line-diff.ts b/apps/web/src/lib/text-diff/line-diff.ts deleted file mode 100644 index 4458025456..0000000000 --- a/apps/web/src/lib/text-diff/line-diff.ts +++ /dev/null @@ -1,285 +0,0 @@ -// Head/tail trim before the quadratic LCS walk, with a hard cap, keeps -// this cheap enough for a dialog on a huge diff. - -export type DiffLineKind = "context" | "added" | "removed" | "skipped"; - -export type DiffLine = { - readonly kind: DiffLineKind; - readonly text: string; - /** Line number in the before revision, or null for an added line. */ - readonly beforeLineNumber: number | null; - /** Line number in the after revision, or null for a removed line. */ - readonly afterLineNumber: number | null; - /** How many unchanged lines a `skipped` row stands in for. */ - readonly skippedLines?: number; -}; - -export type DiffTotals = { - readonly added: number; - readonly removed: number; -}; - -export type DiffLimits = { - /** Most changed lines, per side, the quadratic walk is allowed. */ - readonly maxLines: number; - /** Most characters, across both sides, the quadratic walk is allowed. */ - readonly maxCharacters: number; - /** Unchanged lines kept either side of a change before collapsing. */ - readonly contextLines: number; -}; - -/** Caps the walk at ~1500×1500 cells (about 9MB of Int32Array) — small - * enough to stay imperceptible in a dialog, large enough that a realistic - * document edit is diffed in full. */ -export const DEFAULT_DIFF_LIMITS: DiffLimits = { - maxLines: 1500, - maxCharacters: 400_000, - contextLines: 3, -}; - -export type Diff = - | { readonly status: "identical" } - | { - readonly status: "diffed"; - readonly lines: readonly DiffLine[]; - readonly totals: DiffTotals; - } - | { - readonly status: "too-large"; - readonly beforeLines: number; - readonly afterLines: number; - /** Changed lines per side once the identical head and tail are - * trimmed — the size that actually exceeded the cap. */ - readonly changedBeforeLines: number; - readonly changedAfterLines: number; - }; - -/** One newline convention, so a document saved on Windows or by an editor - * that emits lone carriage returns diffs — and is written back — as the - * same bytes the reader reviewed. */ -export function normalizeNewlines(text: string): string { - return text.replace(/\r\n?/g, "\n"); -} - -function splitLines(text: string): readonly string[] { - if (text === "") return []; - return text.split("\n"); -} - -function characterCount(lines: readonly string[]): number { - let total = 0; - for (const line of lines) total += line.length + 1; - return total; -} - -// A typed array keeps the expensive table to four bytes a cell. -function commonSuffixLengths( - before: readonly string[], - after: readonly string[], -): readonly Int32Array[] { - const table: Int32Array[] = Array.from( - { length: before.length + 1 }, - () => new Int32Array(after.length + 1), - ); - for (let i = before.length - 1; i >= 0; i -= 1) { - const row = table[i]; - const next = table[i + 1]; - if (row === undefined || next === undefined) continue; - for (let j = after.length - 1; j >= 0; j -= 1) { - row[j] = - before[i] === after[j] ? (next[j + 1] ?? 0) + 1 : Math.max(next[j] ?? 0, row[j + 1] ?? 0); - } - } - return table; -} - -/** The add/remove/keep script for the changed middle of the document, with - * line numbers offset by the identical head that was trimmed off. */ -function changedRegionScript( - before: readonly string[], - after: readonly string[], - offset: number, -): readonly DiffLine[] { - const table = commonSuffixLengths(before, after); - const out: DiffLine[] = []; - let i = 0; - let j = 0; - while (i < before.length && j < after.length) { - const beforeLine = before[i] ?? ""; - const afterLine = after[j] ?? ""; - if (beforeLine === afterLine) { - out.push({ - kind: "context", - text: beforeLine, - beforeLineNumber: offset + i + 1, - afterLineNumber: offset + j + 1, - }); - i += 1; - j += 1; - continue; - } - if ((table[i + 1]?.[j] ?? 0) >= (table[i]?.[j + 1] ?? 0)) { - out.push({ - kind: "removed", - text: beforeLine, - beforeLineNumber: offset + i + 1, - afterLineNumber: null, - }); - i += 1; - } else { - out.push({ - kind: "added", - text: afterLine, - beforeLineNumber: null, - afterLineNumber: offset + j + 1, - }); - j += 1; - } - } - while (i < before.length) { - out.push({ - kind: "removed", - text: before[i] ?? "", - beforeLineNumber: offset + i + 1, - afterLineNumber: null, - }); - i += 1; - } - while (j < after.length) { - out.push({ - kind: "added", - text: after[j] ?? "", - beforeLineNumber: null, - afterLineNumber: offset + j + 1, - }); - j += 1; - } - return out; -} - -function contextRun( - lines: readonly string[], - beforeStart: number, - afterStart: number, -): readonly DiffLine[] { - return lines.map((text, index) => ({ - kind: "context" as const, - text, - beforeLineNumber: beforeStart + index + 1, - afterLineNumber: afterStart + index + 1, - })); -} - -/** Collapses every run of unchanged lines longer than `2 * contextLines` - * into the lines nearest the changes around it plus one "skipped" row. */ -function collapseContext(lines: readonly DiffLine[], contextLines: number): readonly DiffLine[] { - const out: DiffLine[] = []; - let index = 0; - while (index < lines.length) { - const line = lines[index]; - if (line === undefined) break; - if (line.kind !== "context") { - out.push(line); - index += 1; - continue; - } - let end = index; - while (lines[end]?.kind === "context") end += 1; - const run = lines.slice(index, end); - const atStart = index === 0; - const atEnd = end === lines.length; - const head = atStart ? 0 : contextLines; - const tail = atEnd ? 0 : contextLines; - if (run.length <= head + tail + 1) { - out.push(...run); - } else { - out.push(...run.slice(0, head)); - const hidden = run.length - head - tail; - out.push({ - kind: "skipped", - text: `${String(hidden)} unchanged ${hidden === 1 ? "line" : "lines"}`, - beforeLineNumber: null, - afterLineNumber: null, - skippedLines: hidden, - }); - out.push(...run.slice(run.length - tail)); - } - index = end; - } - return out; -} - -export function diffTotals(lines: readonly DiffLine[]): DiffTotals { - let added = 0; - let removed = 0; - for (const line of lines) { - if (line.kind === "added") added += 1; - if (line.kind === "removed") removed += 1; - } - return { added, removed }; -} - -// Callers render whichever status comes back rather than computing the -// script a second time. -export function diffText( - beforeRevision: string, - afterRevision: string, - limits: DiffLimits = DEFAULT_DIFF_LIMITS, -): Diff { - const before = normalizeNewlines(beforeRevision); - const after = normalizeNewlines(afterRevision); - if (before === after) return { status: "identical" }; - - const beforeLines = splitLines(before); - const afterLines = splitLines(after); - - let prefix = 0; - while ( - prefix < beforeLines.length && - prefix < afterLines.length && - beforeLines[prefix] === afterLines[prefix] - ) { - prefix += 1; - } - let suffix = 0; - while ( - suffix < beforeLines.length - prefix && - suffix < afterLines.length - prefix && - beforeLines[beforeLines.length - 1 - suffix] === afterLines[afterLines.length - 1 - suffix] - ) { - suffix += 1; - } - - const changedBefore = beforeLines.slice(prefix, beforeLines.length - suffix); - const changedAfter = afterLines.slice(prefix, afterLines.length - suffix); - - if ( - changedBefore.length > limits.maxLines || - changedAfter.length > limits.maxLines || - characterCount(changedBefore) + characterCount(changedAfter) > limits.maxCharacters - ) { - return { - status: "too-large", - beforeLines: beforeLines.length, - afterLines: afterLines.length, - changedBeforeLines: changedBefore.length, - changedAfterLines: changedAfter.length, - }; - } - - const lines = [ - ...contextRun(beforeLines.slice(0, prefix), 0, 0), - ...changedRegionScript(changedBefore, changedAfter, prefix), - ...contextRun( - beforeLines.slice(beforeLines.length - suffix), - beforeLines.length - suffix, - afterLines.length - suffix, - ), - ]; - - return { - status: "diffed", - lines: collapseContext(lines, limits.contextLines), - totals: diffTotals(lines), - }; -} diff --git a/apps/web/src/pages/diff-view.css b/apps/web/src/pages/diff-view.css deleted file mode 100644 index c2dbacd059..0000000000 --- a/apps/web/src/pages/diff-view.css +++ /dev/null @@ -1,114 +0,0 @@ -.diff-view, -.diff-summary { - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -.diff-summary { - gap: 0.25rem; -} - -.diff-summary-text { - font-size: 0.875rem; - line-height: 1.25rem; - color: var(--foreground); -} - -.diff-counts { - font-family: var(--font-mono); - font-size: 0.75rem; - line-height: 1rem; - font-variant-numeric: tabular-nums; - color: var(--muted-foreground); -} - -.diff-scroll { - max-height: 24rem; - overflow: auto; - border-radius: var(--radius); - border: 1px solid var(--border); - background: color-mix(in oklab, var(--muted) 30%, transparent); -} - -.diff-table { - width: 100%; - border-collapse: collapse; - font-family: var(--font-mono); - font-size: 0.75rem; - line-height: 1.625; -} - -.diff-row { - border-bottom-width: 0; -} - -.diff-row:hover { - background: transparent; -} - -.diff-row-context { - color: var(--muted-foreground); -} - -.diff-row-added { - background: color-mix(in oklab, var(--success) 10%, transparent); - color: var(--foreground); -} - -.diff-row-removed { - background: color-mix(in oklab, var(--destructive) 10%, transparent); - color: var(--foreground); -} - -.diff-row-skipped { - color: var(--muted-foreground); - font-style: italic; -} - -.diff-row-added:hover, -.diff-row-removed:hover { - background: transparent; -} - -.diff-cell { - user-select: none; -} - -.diff-cell-number { - width: 2.5rem; - padding-inline: 0.5rem; - text-align: right; - font-variant-numeric: tabular-nums; - color: var(--muted-foreground); -} - -.diff-cell-marker { - width: 1.5rem; - padding-inline: 0.25rem; - text-align: center; -} - -.diff-cell-text { - user-select: auto; - white-space: pre-wrap; - overflow-wrap: break-word; - padding: 0.125rem 0.5rem; -} - -.diff-skip-marker { - margin-inline: auto; -} - -.diff-heading { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 0.5rem; - font-size: 0.75rem; - line-height: 1rem; -} - -.diff-heading-arrow { - color: var(--muted-foreground); -} diff --git a/apps/web/src/pages/diff-view.tsx b/apps/web/src/pages/diff-view.tsx deleted file mode 100644 index 200526ecb1..0000000000 --- a/apps/web/src/pages/diff-view.tsx +++ /dev/null @@ -1,108 +0,0 @@ -// Computes the diff script exactly once per render — the change summary -// reads off the same result the rows come from. - -import { Badge, Table, TableBody, TableCell, TableRow } from "@corbits/react-ui"; -import { diffText } from "@/lib/text-diff"; -import type { DiffLine } from "@/lib/text-diff"; -import { ArrowRight, DotsThree } from "@/lib/icons"; -import { useMemo, type ReactNode } from "react"; -import "./diff-view.css"; - -const MARKER: Record = { - context: " ", - added: "+", - removed: "-", - skipped: