diff --git a/apps/app/package.json b/apps/app/package.json index 200bfed2ca..35ffbbf93f 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -18,6 +18,7 @@ "test": "node scripts/generate-pwa-icons.mjs --check && vitest run --config vitest.config.ts" }, "dependencies": { + "@bb/client-core": "workspace:*", "@bb/config": "workspace:*", "@bb/core-ui": "workspace:*", "@bb/desktop-contract": "workspace:*", diff --git a/apps/app/src/components/promptbox/FollowUpPromptBox.tsx b/apps/app/src/components/promptbox/FollowUpPromptBox.tsx index 4bc0b4bcf0..1ea00dfab6 100644 --- a/apps/app/src/components/promptbox/FollowUpPromptBox.tsx +++ b/apps/app/src/components/promptbox/FollowUpPromptBox.tsx @@ -1,3 +1,4 @@ +import type { FollowUpSubmitMode } from "@bb/client-core"; import { memo, useCallback, @@ -135,30 +136,12 @@ function isKeyboardFocusTarget(target: EventTarget | null): boolean { target instanceof HTMLSelectElement) ); } -/** - * Discriminated state for the composer's submit affordances. Replaces the - * previous canSendFollowUp / canQueueFollowUp / canStopRuntime / onStop - * boolean soup. The caller computes one of these from runtimeDisplayStatus + - * pending-interaction state and passes it down; the composer reads .kind to - * render submit/queue/stop affordances. - */ -export type FollowUpBlockedReason = - | "loading-execution-options" - | "loading-pending-interactions" - | "pending-interaction" - | "provisioning" - | "stopping" - | "unavailable"; - -export type FollowUpSubmitMode = - /** Idle thread — submit creates a new turn; no stop affordance. */ - | { kind: "ready" } - /** Runtime is active or host-reconnecting — submit queues the message; stop the runtime. */ - | { kind: "queue"; onStop: () => void } - /** Runtime is pre-start or waiting on the host — can't send/queue, but can stop. */ - | { kind: "stop-only"; onStop: () => void } - /** Can't submit and can't stop — show why. */ - | { kind: "blocked"; reason: FollowUpBlockedReason }; +// The submit-mode discriminated union lives in @bb/client-core so the shared +// submission policy and the native composer read the same shape. +export type { + FollowUpBlockedReason, + FollowUpSubmitMode, +} from "@bb/client-core"; export interface FollowUpComposerProps { history: HistoryConfig; diff --git a/apps/app/src/components/promptbox/effective-prompt-mode.ts b/apps/app/src/components/promptbox/effective-prompt-mode.ts index b0744a105d..beb9985a27 100644 --- a/apps/app/src/components/promptbox/effective-prompt-mode.ts +++ b/apps/app/src/components/promptbox/effective-prompt-mode.ts @@ -1,71 +1,12 @@ -import { - promptInputHasCommandMention, - type ThreadTimelineActivePromptMode, - type PromptTextMention, -} from "@bb/domain"; - -export interface PromptModeInput { - mentionRanges: readonly PromptTextMention[]; - providerId: string | undefined; - value: string; -} - -export interface PermissionDisplayOverride { - label: string; - compactLabel?: string; - description?: string; - title?: string; -} - -const CLAUDE_PLAN_PERMISSION_DISPLAY: PermissionDisplayOverride = { - label: "Plan Mode", - compactLabel: "Plan", - description: "Claude Code will plan without normal full-access execution.", -}; - -export function isClaudePlanModePrompt({ - mentionRanges, - providerId, - value, -}: PromptModeInput): boolean { - return ( - providerId === "claude-code" && - promptInputHasCommandMention( - [{ type: "text", text: value, mentions: [...mentionRanges] }], - { trigger: "/", name: "plan" }, - ) - ); -} - -export function permissionDisplayForPromptMode( - args: PromptModeInput, -): PermissionDisplayOverride | undefined { - if (!isClaudePlanModePrompt(args)) { - return undefined; - } - return CLAUDE_PLAN_PERMISSION_DISPLAY; -} - -export function permissionDisplayForActivePromptMode( - activePromptMode: ThreadTimelineActivePromptMode | null | undefined, -): PermissionDisplayOverride | undefined { - if ( - activePromptMode?.mode === "plan" && - activePromptMode.providerId === "claude-code" - ) { - return CLAUDE_PLAN_PERMISSION_DISPLAY; - } - return undefined; -} - -export function shouldDisablePermissionPickerForPromptMode( - args: PromptModeInput, -): boolean { - return isClaudePlanModePrompt(args); -} - -export function shouldDisablePermissionPickerForActivePromptMode( - activePromptMode: ThreadTimelineActivePromptMode | null | undefined, -): boolean { - return activePromptMode?.mode === "plan"; -} +// Moved to @bb/client-core (shared with the native app); re-exported here so web imports keep resolving. +export { + isClaudePlanModePrompt, + permissionDisplayForPromptMode, + permissionDisplayForActivePromptMode, + shouldDisablePermissionPickerForPromptMode, + shouldDisablePermissionPickerForActivePromptMode, +} from "@bb/client-core"; +export type { + PromptModeInput, + PermissionDisplayOverride, +} from "@bb/client-core"; diff --git a/apps/app/src/components/promptbox/mentions/command-trigger.ts b/apps/app/src/components/promptbox/mentions/command-trigger.ts index 3d60537aff..f3a07500e5 100644 --- a/apps/app/src/components/promptbox/mentions/command-trigger.ts +++ b/apps/app/src/components/promptbox/mentions/command-trigger.ts @@ -1,72 +1,11 @@ -import type { - ProviderComposerCommand, - PromptMentionCommandTrigger, - ProviderComposerAction, -} from "@bb/domain"; - -export type ProviderPromptActionCommand = ProviderComposerCommand; - -export interface ProviderPromptAction { - kind: "goal" | "plan" | "skills"; - text: string; - command?: ProviderPromptActionCommand; -} - -export interface ProviderPromptActionProps { - skillsTrigger: PromptMentionCommandTrigger | null; - promptActions: readonly ProviderPromptAction[]; -} - -/** - * Maps provider-owned composer metadata into the prompt action shape consumed - * by app hosts. - */ -export function buildProviderPromptActionProps( - composerActions: readonly ProviderComposerAction[], -): ProviderPromptActionProps { - const promptActions: ProviderPromptAction[] = []; - let skillsTrigger: PromptMentionCommandTrigger | null = null; - - for (const action of composerActions) { - switch (action.kind) { - case "skills": - skillsTrigger = action.trigger; - promptActions.push({ - kind: action.kind, - text: action.trigger, - }); - break; - case "goal": - case "plan": - promptActions.push({ - kind: action.kind, - command: action.command, - text: serializedProviderCommand(action.command), - }); - break; - } - } - - return { skillsTrigger, promptActions }; -} - -export function serializedProviderCommand( - command: ProviderComposerCommand, -): string { - return `${command.trigger}${command.name}${command.trailingText}`; -} - -/** - * A selected command is a one-position mention atom in the editor doc. The - * dismissed range is based on that rendered node width plus any space inserted - * after it, not on the serialized provider token length (`/review`, etc.). - */ -export function commandPillDismissedRangeEnd({ - triggerPosition, - trailingText, -}: { - triggerPosition: number; - trailingText: string; -}): number { - return triggerPosition + 1 + trailingText.length; -} +// Moved to @bb/client-core (shared with the native app); re-exported here so web imports keep resolving. +export { + buildProviderPromptActionProps, + serializedProviderCommand, + commandPillDismissedRangeEnd, +} from "@bb/client-core"; +export type { + ProviderPromptActionCommand, + ProviderPromptAction, + ProviderPromptActionProps, +} from "@bb/client-core"; diff --git a/apps/app/src/components/promptbox/mentions/find-active-trigger.ts b/apps/app/src/components/promptbox/mentions/find-active-trigger.ts index 5f35488720..f57dc27583 100644 --- a/apps/app/src/components/promptbox/mentions/find-active-trigger.ts +++ b/apps/app/src/components/promptbox/mentions/find-active-trigger.ts @@ -1,119 +1,3 @@ -import type { Editor } from "@tiptap/react"; -import type { - ActiveTrigger, - TypeaheadTrigger, -} from "@/components/promptbox/mentions/types"; - -interface ActiveTriggerEditor { - state: { - selection: { - empty: boolean; - from: number; - }; - doc: { - textBetween( - from: number, - to: number, - blockSeparator?: string, - leafText?: string, - ): string; - }; - }; -} - -/** - * Builds the word-boundary detection regex for a trigger char. A trigger only - * fires at the start of input or after whitespace / an opening bracket, so a - * mid-word `a/b` or `foo@bar` never opens a menu. - * - * - mention triggers keep a per-char self-exclusion query class, so a second - * trigger char ends the current query rather than extending it (`##` stays a - * markdown heading, not a `#` mention query). - * - command triggers (`/`) capture the whole token up to whitespace - * (`\S*`), so a namespaced name like `frontend:component` is captured whole. - */ -function escapeRegexLiteral(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); -} - -function triggerPattern( - trigger: TypeaheadTrigger, - options: { windowed: boolean }, -): RegExp { - const escapedChar = escapeRegexLiteral(trigger.char); - const queryClass = - trigger.kind === "mention" ? `[^\\s${escapedChar}]*` : "\\S*"; - // In a windowed scan the window start is not the start of input, so the - // `^` alternative must not fire there; a real trigger inside the window - // always carries its boundary char (the window includes one extra char - // beyond the longest recognizable query). - const boundary = options.windowed ? "([\\s([{])" : "(^|[\\s([{])"; - return new RegExp(`${boundary}${escapedChar}(${queryClass})$`, "u"); -} - -/** - * How many characters before the caret are scanned for a trigger. Trigger - * queries are short human-typed tokens (skill/command names, mention - * queries); scanning the full document instead would rebuild and regex-scan - * the entire text on every keystroke and selection change, which costs - * several ms once a large paste (e.g. a minified JS bundle) is in the box. A - * trigger whose query exceeds the window no longer opens the menu — at that - * length no menu has useful matches anyway. - */ -const TRIGGER_SCAN_WINDOW = 256; - -/** - * Resolves the typeahead trigger currently under the caret, if any. Replaces the - * single-`@` `findActiveEditorMention`: it scans the configured `triggers` in - * order and returns the first whose pattern matches the text before the caret. - * Because a thread is bound to one provider, the active set is at most `@` plus - * one command trigger, so order only matters when both could match (they can't — - * the leading char differs). - * - * Returns `null` when the selection is non-empty (a range, not a caret) or no - * trigger matches. - */ -export function findActiveTrigger( - editor: ActiveTriggerEditor | Editor, - triggers: readonly TypeaheadTrigger[], -): ActiveTrigger | null { - const selection = editor.state.selection; - if (!selection.empty) return null; - - const scanStart = Math.max(0, selection.from - TRIGGER_SCAN_WINDOW); - const windowed = scanStart > 0; - const textBeforeCursor = editor.state.doc.textBetween( - scanStart, - selection.from, - "\n", - "\n", - ); - - for (const trigger of triggers) { - const match = triggerPattern(trigger, { windowed }).exec(textBeforeCursor); - if (!match) continue; - - const query = match[2] ?? ""; - const from = selection.from - query.length - 1; - if (from < 0) continue; - - if (trigger.kind === "mention") { - return { - char: trigger.char, - kind: "mention", - query, - from, - to: selection.from, - }; - } - return { - char: trigger.char, - kind: "command", - query, - from, - to: selection.from, - }; - } - - return null; -} +// Moved to @bb/client-core (shared with the native app); re-exported here so web imports keep resolving. +export { findActiveTrigger } from "@bb/client-core"; +export type { ActiveTriggerEditor } from "@bb/client-core"; diff --git a/apps/app/src/components/promptbox/mentions/types.ts b/apps/app/src/components/promptbox/mentions/types.ts index c1e2ceda4f..5512ed8c33 100644 --- a/apps/app/src/components/promptbox/mentions/types.ts +++ b/apps/app/src/components/promptbox/mentions/types.ts @@ -1,296 +1,18 @@ -import { - providerCommandSection, - providerCommandSectionRank, - type ProviderCommand, - type ProviderCommandOrigin, - type ProviderCommandSection, - type ProviderCommandSource, -} from "@bb/server-contract"; -import type { PromptMentionCommandTrigger } from "@bb/domain"; -import type { PluginMentionTrigger } from "@/lib/plugin-mention-triggers"; - -export type PromptPathMentionSource = "workspace" | "thread-storage"; -export type PromptPathMentionEntryKind = "file" | "directory"; - -/** - * One row in the mention menu. The `replacement` field is the literal text - * inserted into the prompt after the user picks the suggestion (e.g. - * `apps/app/src/foo.ts` for workspace files, - * `thread-storage:notes/foo.md` for thread-storage files, - * `thread:thr_abc` for threads, or `project:proj_abc` for projects). - */ -export type PromptMentionSuggestion = - | { - kind: "path"; - source: PromptPathMentionSource; - entryKind: PromptPathMentionEntryKind; - path: string; - name: string; - replacement: string; - } - | { - kind: "thread"; - path: string; - replacement: string; - projectId: string; - projectName?: string; - threadId: string; - title?: string; - } - | { - kind: "project"; - path: string; - replacement: string; - projectId: string; - name: string; - } - | { - kind: "section"; - path: string; - replacement: string; - sectionId: string; - name: string; - } - | { - /** - * One plugin mention-provider row (plugin design §4.9), from - * GET /plugins/mentions/search. Items group under `providerLabel` in - * the menu; picking one inserts a pill whose resource carries - * `pluginId` + the opaque `itemId` the server resolves at send time. - */ - kind: "plugin"; - pluginId: string; - /** Provider id within the plugin; with pluginId it identifies the - * menu section (labels alone can collide across plugins). */ - providerId: string; - itemId: string; - providerLabel: string; - title: string; - subtitle: string | null; - /** Named shared-UI icon hint supplied by the plugin item. */ - icon: string | null; - replacement: string; - }; - -/** - * One row in the command typeahead menu, derived from a {@link ProviderCommand} - * returned by `GET /projects/:id/commands`. The `kind: "command"` discriminant - * lets it join the same menu union as {@link PromptMentionSuggestion} while the - * composer's apply path inserts a prompt pill that serializes back to the - * slash command token (`/`). - */ -export interface ProviderCommandSuggestion { - kind: "command"; - name: string; - source: ProviderCommandSource; - origin: ProviderCommandOrigin; - description: string | null; - argumentHint: string | null; - pluginId?: string; -} - -/** - * Build a {@link ProviderCommandSuggestion} from the wire-level - * {@link ProviderCommand}. The only difference is the `kind` discriminant that - * slots the record into the menu's suggestion union. - */ -export function toProviderCommandSuggestion( - command: ProviderCommand, -): ProviderCommandSuggestion { - return { - kind: "command", - name: command.name, - source: command.source, - origin: command.origin, - description: command.description, - argumentHint: command.argumentHint, - ...(command.pluginId !== undefined ? { pluginId: command.pluginId } : {}), - }; -} - -/** Every row the command typeahead menu can render. */ -export type ComposerCommandSuggestion = ProviderCommandSuggestion; - -function compareCommandSuggestionSections( - left: ComposerCommandSuggestion, - right: ComposerCommandSuggestion, -): number { - return providerCommandSectionRank(left) - providerCommandSectionRank(right); -} - -/** - * The names a query can address a command by. A namespaced skill - * (`ottonomous:review`) also answers to its trailing segment, so typing the - * bare name still counts as naming that skill. - */ -function commandSuggestionSearchNames( - suggestion: ComposerCommandSuggestion, -): string[] { - const name = suggestion.name.toLowerCase(); - if (suggestion.source !== "skill") { - return [name]; - } - const separatorIndex = name.lastIndexOf(":"); - return separatorIndex < 0 ? [name] : [name, name.slice(separatorIndex + 1)]; -} - -/** - * How directly the query names a command. Lower wins: the whole canonical - * name, then a namespaced skill's bare alias, then a name prefix, then a row - * that only matched through its description or argument hint. An empty query - * prefix-matches everything, so it ranks every row alike. - */ -function commandSuggestionMatchRank( - suggestion: ComposerCommandSuggestion, - normalizedQuery: string, -): number { - const canonicalName = suggestion.name.toLowerCase(); - if (canonicalName === normalizedQuery) { - return 0; - } - const names = commandSuggestionSearchNames(suggestion); - if (names.includes(normalizedQuery)) { - return 1; - } - return names.some((name) => name.startsWith(normalizedQuery)) ? 2 : 3; -} - -/** - * Relevance order for a lowercased, trimmed query. How directly the query names - * a command outranks which section that command lives in: typing `/plan` in full - * is an unambiguous request for the `plan` user command, and even a partial - * `/pla` names it more directly than a skill that merely mentions "plan" in its - * description. Matches of equal quality keep the `PROVIDER_COMMAND_SECTIONS` - * order, so an empty query — which prefix-matches every row — leaves pure - * section order. - */ -export function compareCommandSuggestions( - left: ComposerCommandSuggestion, - right: ComposerCommandSuggestion, - normalizedQuery: string, -): number { - const byMatch = - commandSuggestionMatchRank(left, normalizedQuery) - - commandSuggestionMatchRank(right, normalizedQuery); - return byMatch !== 0 - ? byMatch - : compareCommandSuggestionSections(left, right); -} - -/** - * Put the flat command list in the exact order the menu renders it: ranked by - * {@link compareCommandSuggestions}, then collapsed so every section's rows are - * contiguous, ordered by where that section first appears — which puts each - * section under its own best match. The collapse is what keeps hoisting a strong - * match honest: the menu groups by section as it renders, so a section whose - * rows were scattered through the flat list would paint them in a different - * order than the composer walks them. The composer uses this exact array for - * keyboard navigation and Enter/Tab apply, so visual grouping must never be the - * first place ordering happens. - */ -export function orderCommandSuggestions( - suggestions: readonly ComposerCommandSuggestion[], - query: string, -): ComposerCommandSuggestion[] { - const normalizedQuery = query.trim().toLowerCase(); - const ranked = [...suggestions].sort((left, right) => - compareCommandSuggestions(left, right, normalizedQuery), - ); - - const bySection = new Map< - ProviderCommandSection, - ComposerCommandSuggestion[] - >(); - for (const suggestion of ranked) { - const section = providerCommandSection(suggestion); - const existing = bySection.get(section); - if (existing) { - existing.push(suggestion); - continue; - } - bySection.set(section, [suggestion]); - } - return [...bySection.values()].flat(); -} - -/** - * A typeahead trigger the composer watches for. Mention triggers open the - * mention menu and the provider-owned command trigger opens the command menu. - * A thread is bound to one provider, so at most one command trigger is ever - * active in a composer. - */ -export type TypeaheadTrigger = - | { char: PluginMentionTrigger; kind: "mention" } - | { char: PromptMentionCommandTrigger; kind: "command" }; - -/** - * The trigger currently under the caret, resolved by the composer's - * word-boundary detection. `from` is the document position of the trigger char - * and `to` is the caret position; `query` is the text typed after the trigger - * up to the caret (whole namespaced names like `frontend:component` are - * captured, stopping at whitespace). - */ -export type ActiveTrigger = - | { - char: PluginMentionTrigger; - kind: "mention"; - query: string; - from: number; - to: number; - } - | { - char: PromptMentionCommandTrigger; - kind: "command"; - query: string; - from: number; - to: number; - }; - -/** - * Mutually-exclusive states the mention menu can render. Replaces the prior - * 4-boolean flag soup (showQueryHint / mentionLoading / mentionError / - * mentionSuggestions). The "results" state's empty-vs-populated rendering is - * a single decision inside the menu (`suggestions.length === 0` shows the - * empty state). - */ -export type MentionMenuState = - /** User typed `@` but no query yet. */ - | { kind: "hint" } - /** Suggestions request in flight. */ - | { kind: "loading" } - /** Suggestions request failed. */ - | { kind: "error" } - /** Suggestions resolved (possibly empty). */ - | { - kind: "results"; - suggestions: readonly PromptMentionSuggestion[]; - }; - -/** - * Mutually-exclusive states the command typeahead menu can render. Mirrors - * {@link MentionMenuState} but with no "hint" state: command triggers show the - * full available list immediately (no "type to search" gate). The composer - * suppresses opening the menu entirely on a loaded-empty result, so an empty - * `results` state is only reached transiently. - */ -export type CommandMenuState = - /** Suggestions request in flight. */ - | { kind: "loading" } - /** Suggestions request failed. */ - | { kind: "error" } - /** Suggestions resolved (possibly empty). */ - | { - kind: "results"; - suggestions: readonly ComposerCommandSuggestion[]; - }; - -/** - * Generalized typeahead menu state covering both trigger kinds. The `trigger` - * discriminant tells the menu which suggestion shape it is rendering so a - * single `MentionMenu` can present mention sections or command sections without - * forking. The §6 menu task consumes this; §5 composer task produces it from - * the active trigger plus the matching data hook. - */ -export type TypeaheadMenuState = - | { trigger: "mention"; state: MentionMenuState } - | { trigger: "command"; state: CommandMenuState }; +// Moved to @bb/client-core (shared with the native app); re-exported here so web imports keep resolving. +export { + toProviderCommandSuggestion, + compareCommandSuggestions, + orderCommandSuggestions, +} from "@bb/client-core"; +export type { + PromptPathMentionSource, + PromptPathMentionEntryKind, + PromptMentionSuggestion, + ProviderCommandSuggestion, + ComposerCommandSuggestion, + TypeaheadTrigger, + ActiveTrigger, + MentionMenuState, + CommandMenuState, + TypeaheadMenuState, +} from "@bb/client-core"; diff --git a/apps/app/src/components/secondary-panel/secondaryPanelTabState.ts b/apps/app/src/components/secondary-panel/secondaryPanelTabState.ts index a80dea5253..51050ffe7a 100644 --- a/apps/app/src/components/secondary-panel/secondaryPanelTabState.ts +++ b/apps/app/src/components/secondary-panel/secondaryPanelTabState.ts @@ -1,500 +1,26 @@ -import { arrayMove } from "@dnd-kit/sortable"; -import { - areFixedPanelTabsEquivalent, - type BrowserFixedPanelTab, - type FixedPanelTab, - type FixedPanelTabsState, - type FixedPanelViewTab, - type NewTabFixedPanelTab, - type SecondaryFileFixedPanelTab, - type SecondaryFixedPanelTab, - type ThreadStorageFilePreviewFixedPanelTab, - type WorkspaceFilePreviewFixedPanelTab, -} from "@/lib/fixed-panel-tabs-state"; - -interface SetSecondaryPanelTabsInStateArgs { - activeTabId: string | null; - isOpen: boolean; - state: FixedPanelTabsState; - tabs: readonly FixedPanelTab[]; -} - -interface OpenSecondaryPanelTabInStateArgs { - state: FixedPanelTabsState; - tab: FixedPanelTab; -} - -interface ReplaceNewTabWithSecondaryPanelTabInStateArgs { - state: FixedPanelTabsState; - tab: FixedPanelTab; -} - -interface UpdateSecondaryPanelTabInStateArgs { - state: FixedPanelTabsState; - tab: FixedPanelTab; -} - -interface ReorderSecondaryPanelFileTabInStateArgs { - activeTabId: string; - overTabId: string; - state: FixedPanelTabsState; -} - -interface GetActiveTabIdAfterCloseArgs { - activeTabId: string | null; - closedTabId: string; - tabsBeforeClose: readonly FixedPanelTab[]; - tabsAfterClose: readonly FixedPanelTab[]; -} - -interface BuildOrderedSecondaryPanelFileTabsArgs { - includeWorkspaceTabsOutsideEnvironment?: boolean; - tabs: readonly FixedPanelTab[]; - resolvedEnvironmentId: string | null | undefined; -} - -interface PruneStorageTabsArgs { - knownPaths: ReadonlySet; - tabs: readonly FixedPanelTab[]; - threadId: string | null | undefined; -} - -interface ReconcileFixedPanelViewTabsInStateArgs { - fixedTabs: readonly FixedPanelViewTab[]; - openFirstFixedTabWhenEmpty?: boolean; - state: FixedPanelTabsState; -} - -export function isWorkspaceFilePreviewTab( - tab: FixedPanelTab, -): tab is WorkspaceFilePreviewFixedPanelTab { - return tab.kind === "workspace-file-preview"; -} - -export function isStorageFilePreviewTab( - tab: FixedPanelTab, -): tab is ThreadStorageFilePreviewFixedPanelTab { - return tab.kind === "thread-storage-file-preview"; -} - -export function isBrowserTab(tab: FixedPanelTab): tab is BrowserFixedPanelTab { - return tab.kind === "browser"; -} - -export function isNewTab(tab: FixedPanelTab): tab is NewTabFixedPanelTab { - return tab.kind === "new-tab"; -} - -export function isSecondaryFileTab( - tab: FixedPanelTab, -): tab is SecondaryFileFixedPanelTab { - switch (tab.kind) { - case "workspace-file-preview": - case "host-file-preview": - case "thread-storage-file-preview": - case "browser": - case "terminal": - case "new-tab": - case "plugin-panel": - return true; - case "thread-info": - case "git-diff": - case "plugin-page-fixed": - return false; - } -} - -export function isFixedPanelViewTab( - tab: FixedPanelTab, -): tab is FixedPanelViewTab { - return !isSecondaryFileTab(tab); -} - -export function reconcileFixedPanelViewTabsInState({ - fixedTabs, - openFirstFixedTabWhenEmpty = false, - state, -}: ReconcileFixedPanelViewTabsInStateArgs): FixedPanelTabsState { - const contentTabs = state.secondary.tabs.filter(isSecondaryFileTab); - const tabs: readonly FixedPanelTab[] = [...fixedTabs, ...contentTabs]; - const tabsAreEquivalent = - tabs.length === state.secondary.tabs.length && - tabs.every((tab, index) => { - const current = state.secondary.tabs[index]; - return current !== undefined && areFixedPanelTabsEquivalent(tab, current); - }); - const activeTabStillExists = tabs.some( - (tab) => tab.id === state.secondary.activeTabId, - ); - const activeTabId = activeTabStillExists - ? state.secondary.activeTabId - : (fixedTabs[0]?.id ?? contentTabs[0]?.id ?? null); - const isFirstInitialization = - state.secondary.tabs.length === 0 && - state.secondary.activeTabId === null && - !state.secondary.isOpen; - const isOpen = - activeTabId !== null && - (state.secondary.isOpen || - (openFirstFixedTabWhenEmpty && - isFirstInitialization && - fixedTabs.length > 0)); - - if ( - tabsAreEquivalent && - activeTabId === state.secondary.activeTabId && - isOpen === state.secondary.isOpen - ) { - return state; - } - return setSecondaryPanelTabsInState({ - activeTabId, - isOpen, - state, - tabs, - }); -} - -export function getActiveSecondaryPanelTab( - state: FixedPanelTabsState, -): SecondaryFixedPanelTab | null { - const activeTabId = state.secondary.activeTabId; - if (activeTabId === null) { - return null; - } - return ( - state.secondary.tabs.find( - (tab): tab is SecondaryFixedPanelTab => tab.id === activeTabId, - ) ?? null - ); -} - -export function findSecondaryPanelTab( - tabs: readonly FixedPanelTab[], - tabId: string, -): FixedPanelTab | null { - return tabs.find((tab) => tab.id === tabId) ?? null; -} - -export function setSecondaryPanelTabsInState({ - activeTabId, - isOpen, - state, - tabs, -}: SetSecondaryPanelTabsInStateArgs): FixedPanelTabsState { - if ( - tabs === state.secondary.tabs && - activeTabId === state.secondary.activeTabId && - isOpen === state.secondary.isOpen - ) { - return state; - } - - return { - ...state, - secondary: { - tabs, - activeTabId, - isOpen, - }, - }; -} - -export function upsertSecondaryPanelTab( - tabs: readonly FixedPanelTab[], - tab: FixedPanelTab, -): readonly FixedPanelTab[] { - const existingTabIndex = tabs.findIndex( - (currentTab) => currentTab.id === tab.id, - ); - if (existingTabIndex === -1) { - return [...tabs, tab]; - } - - const existingTab = tabs[existingTabIndex]; - if (existingTab && areFixedPanelTabsEquivalent(existingTab, tab)) { - return tabs; - } - - return tabs.map((currentTab) => - currentTab.id === tab.id ? tab : currentTab, - ); -} - -export function removeSecondaryPanelTab( - tabs: readonly FixedPanelTab[], - tabId: string, -): readonly FixedPanelTab[] { - const nextTabs = tabs.filter((tab) => tab.id !== tabId); - return nextTabs.length === tabs.length ? tabs : nextTabs; -} - -export function openSecondaryPanelTabInState({ - state, - tab, -}: OpenSecondaryPanelTabInStateArgs): FixedPanelTabsState { - const tabs = upsertSecondaryPanelTab(state.secondary.tabs, tab); - if ( - tabs === state.secondary.tabs && - state.secondary.activeTabId === tab.id && - state.secondary.isOpen - ) { - return state; - } - return setSecondaryPanelTabsInState({ - activeTabId: tab.id, - isOpen: true, - state, - tabs, - }); -} - -export function replaceNewTabWithSecondaryPanelTabInState({ - state, - tab, -}: ReplaceNewTabWithSecondaryPanelTabInStateArgs): FixedPanelTabsState { - const newTab = state.secondary.tabs.find(isNewTab) ?? null; - const tabsWithoutNewTab = - newTab === null - ? state.secondary.tabs - : removeSecondaryPanelTab(state.secondary.tabs, newTab.id); - const existingPreviewTab = tabsWithoutNewTab.find( - (currentTab) => currentTab.id === tab.id, - ); - - if (existingPreviewTab) { - return setSecondaryPanelTabsInState({ - activeTabId: existingPreviewTab.id, - isOpen: true, - state, - tabs: tabsWithoutNewTab, - }); - } - - const tabs = - newTab === null - ? upsertSecondaryPanelTab(tabsWithoutNewTab, tab) - : state.secondary.tabs.map((currentTab) => - currentTab.id === newTab.id ? tab : currentTab, - ); - - return setSecondaryPanelTabsInState({ - activeTabId: tab.id, - isOpen: true, - state, - tabs, - }); -} - -export function updateSecondaryPanelTabInState({ - state, - tab, -}: UpdateSecondaryPanelTabInStateArgs): FixedPanelTabsState { - const tabs = upsertSecondaryPanelTab(state.secondary.tabs, tab); - if (tabs === state.secondary.tabs) { - return state; - } - return setSecondaryPanelTabsInState({ - activeTabId: state.secondary.activeTabId, - isOpen: state.secondary.isOpen, - state, - tabs, - }); -} - -export function activateSecondaryPanelTabInState( - state: FixedPanelTabsState, - tabId: string, -): FixedPanelTabsState { - const tab = findSecondaryPanelTab(state.secondary.tabs, tabId); - if (!tab) { - return state; - } - if (state.secondary.activeTabId === tab.id && state.secondary.isOpen) { - return state; - } - return setSecondaryPanelTabsInState({ - activeTabId: tab.id, - isOpen: true, - state, - tabs: state.secondary.tabs, - }); -} - -function getActiveTabIdAfterClose({ - activeTabId, - closedTabId, - tabsBeforeClose, - tabsAfterClose, -}: GetActiveTabIdAfterCloseArgs): string | null { - if (activeTabId !== closedTabId) { - return activeTabId; - } - - const fileTabsBeforeClose = tabsBeforeClose.filter(isSecondaryFileTab); - const closedFileTabIndex = fileTabsBeforeClose.findIndex( - (tab) => tab.id === closedTabId, - ); - if (closedFileTabIndex === -1) { - return null; - } - - const fileTabsAfterClose = tabsAfterClose.filter(isSecondaryFileTab); - const nextActiveTab = - fileTabsAfterClose[closedFileTabIndex] ?? - fileTabsAfterClose[closedFileTabIndex - 1] ?? - null; - return nextActiveTab?.id ?? null; -} - -export function closeSecondaryPanelTabInState( - state: FixedPanelTabsState, - tabId: string, -): FixedPanelTabsState { - const tab = findSecondaryPanelTab(state.secondary.tabs, tabId); - if (tab === null) { - return state; - } - const isClosingActiveTab = state.secondary.activeTabId === tabId; - - const tabs = removeSecondaryPanelTab(state.secondary.tabs, tabId); - if (tabs === state.secondary.tabs) { - return state; - } - - // Closing the last active content tab falls back to a remaining fixed tab. - // Only a genuinely empty panel closes; closing content never creates New tab. - if ( - isClosingActiveTab && - isSecondaryFileTab(tab) && - !tabs.some(isSecondaryFileTab) - ) { - const fallbackTab = tabs[0] ?? null; - return setSecondaryPanelTabsInState({ - activeTabId: fallbackTab?.id ?? null, - isOpen: fallbackTab !== null, - state, - tabs, - }); - } - - return setSecondaryPanelTabsInState({ - activeTabId: getActiveTabIdAfterClose({ - activeTabId: state.secondary.activeTabId, - closedTabId: tabId, - tabsBeforeClose: state.secondary.tabs, - tabsAfterClose: tabs, - }), - isOpen: state.secondary.isOpen, - state, - tabs, - }); -} - -export function reorderSecondaryPanelFileTabInState({ - activeTabId, - overTabId, - state, -}: ReorderSecondaryPanelFileTabInStateArgs): FixedPanelTabsState { - if (activeTabId === overTabId) { - return state; - } - const activeIndex = state.secondary.tabs.findIndex( - (tab) => tab.id === activeTabId && isSecondaryFileTab(tab), - ); - const overIndex = state.secondary.tabs.findIndex( - (tab) => tab.id === overTabId && isSecondaryFileTab(tab), - ); - if (activeIndex === -1 || overIndex === -1) { - return state; - } - return setSecondaryPanelTabsInState({ - activeTabId: state.secondary.activeTabId, - isOpen: state.secondary.isOpen, - state, - tabs: arrayMove([...state.secondary.tabs], activeIndex, overIndex), - }); -} - -export function clearActiveSecondaryFileTabInState( - state: FixedPanelTabsState, -): FixedPanelTabsState { - const activeTab = getActiveSecondaryPanelTab(state); - if (!activeTab || !isSecondaryFileTab(activeTab)) { - return state; - } - return setSecondaryPanelTabsInState({ - activeTabId: null, - isOpen: state.secondary.isOpen, - state, - tabs: state.secondary.tabs, - }); -} - -export function removeWorkspaceTabsForOtherEnvironments( - tabs: readonly FixedPanelTab[], - environmentId: string | null, -): readonly FixedPanelTab[] { - const nextTabs = tabs.filter( - (tab) => - !isWorkspaceFilePreviewTab(tab) || tab.environmentId === environmentId, - ); - return nextTabs.length === tabs.length ? tabs : nextTabs; -} - -export function pruneStorageTabs({ - knownPaths, - tabs, - threadId, -}: PruneStorageTabsArgs): readonly FixedPanelTab[] { - const nextTabs = tabs.filter( - (tab) => - !isStorageFilePreviewTab(tab) || - (tab.threadId !== null && tab.threadId !== threadId) || - knownPaths.has(tab.path), - ); - return nextTabs.length === tabs.length ? tabs : nextTabs; -} - -export function getActiveTabIdAfterPrune( - tabs: readonly FixedPanelTab[], - activeTabId: string | null, -): string | null { - return activeTabId !== null && tabs.some((tab) => tab.id === activeTabId) - ? activeTabId - : null; -} - -export function buildOrderedSecondaryPanelFileTabs({ - includeWorkspaceTabsOutsideEnvironment = false, - tabs, - resolvedEnvironmentId, -}: BuildOrderedSecondaryPanelFileTabsArgs): readonly SecondaryFileFixedPanelTab[] { - const displayable: SecondaryFileFixedPanelTab[] = []; - for (const tab of tabs) { - switch (tab.kind) { - case "workspace-file-preview": - if ( - includeWorkspaceTabsOutsideEnvironment || - (resolvedEnvironmentId !== undefined && - tab.environmentId === resolvedEnvironmentId) - ) { - displayable.push(tab); - } - break; - case "host-file-preview": - case "browser": - case "terminal": - case "new-tab": - case "thread-storage-file-preview": - case "plugin-panel": - displayable.push(tab); - break; - case "thread-info": - case "git-diff": - case "plugin-page-fixed": - break; - } - } - return displayable; -} +// Moved to @bb/client-core (shared with the native app); re-exported here so web imports keep resolving. +export { + isWorkspaceFilePreviewTab, + isStorageFilePreviewTab, + isBrowserTab, + isNewTab, + isSecondaryFileTab, + isFixedPanelViewTab, + reconcileFixedPanelViewTabsInState, + getActiveSecondaryPanelTab, + findSecondaryPanelTab, + setSecondaryPanelTabsInState, + upsertSecondaryPanelTab, + removeSecondaryPanelTab, + openSecondaryPanelTabInState, + replaceNewTabWithSecondaryPanelTabInState, + updateSecondaryPanelTabInState, + activateSecondaryPanelTabInState, + closeSecondaryPanelTabInState, + reorderSecondaryPanelFileTabInState, + clearActiveSecondaryFileTabInState, + removeWorkspaceTabsForOtherEnvironments, + pruneStorageTabs, + getActiveTabIdAfterPrune, + buildOrderedSecondaryPanelFileTabs, +} from "@bb/client-core"; diff --git a/apps/app/src/components/sidebar/machineThreadGroups.ts b/apps/app/src/components/sidebar/machineThreadGroups.ts index 9d12d84820..662abcc69d 100644 --- a/apps/app/src/components/sidebar/machineThreadGroups.ts +++ b/apps/app/src/components/sidebar/machineThreadGroups.ts @@ -1,68 +1,6 @@ -import type { Host, ThreadListEntry } from "@bb/domain"; - -// Group key for threads whose environment has no host (plain chats). Host ids -// are prefixed (e.g. "host_…"), so the sentinel cannot collide with one. -export const NO_MACHINE_GROUP_KEY = "no-machine"; - -export interface MachineThreadGroup { - /** Host id, or {@link NO_MACHINE_GROUP_KEY}. */ - key: string; - label: string; - threads: ThreadListEntry[]; -} - -/** - * Buckets threads by the host their environment runs on, for the sidebar's - * "By machine" view. Groups follow server host order; hosts the server no - * longer lists keep a stable id-ordered section; machineless threads land in - * a trailing "No machine" group. Machines without threads get no group. - */ -export function buildMachineThreadGroups( - threads: readonly ThreadListEntry[], - hosts: readonly Host[], -): MachineThreadGroup[] { - const threadsByKey = new Map(); - for (const thread of threads) { - const key = thread.environmentHostId ?? NO_MACHINE_GROUP_KEY; - const existing = threadsByKey.get(key); - if (existing) { - existing.push(thread); - } else { - threadsByKey.set(key, [thread]); - } - } - - const groups: MachineThreadGroup[] = []; - for (const host of hosts) { - const hostThreads = threadsByKey.get(host.id); - if (!hostThreads) { - continue; - } - threadsByKey.delete(host.id); - groups.push({ key: host.id, label: host.name, threads: hostThreads }); - } - - const noMachineThreads = threadsByKey.get(NO_MACHINE_GROUP_KEY); - threadsByKey.delete(NO_MACHINE_GROUP_KEY); - - const unknownHostIds = Array.from(threadsByKey.keys()).sort((left, right) => - left.localeCompare(right), - ); - for (const hostId of unknownHostIds) { - groups.push({ - key: hostId, - label: "Unknown machine", - threads: threadsByKey.get(hostId) ?? [], - }); - } - - if (noMachineThreads) { - groups.push({ - key: NO_MACHINE_GROUP_KEY, - label: "No machine", - threads: noMachineThreads, - }); - } - - return groups; -} +// Moved to @bb/client-core (shared with the native app); re-exported here so web imports keep resolving. +export { + NO_MACHINE_GROUP_KEY, + buildMachineThreadGroups, +} from "@bb/client-core"; +export type { MachineThreadGroup } from "@bb/client-core"; diff --git a/apps/app/src/components/sidebar/pinnedSidebarThreads.ts b/apps/app/src/components/sidebar/pinnedSidebarThreads.ts index cde7186360..92bc6e5f9b 100644 --- a/apps/app/src/components/sidebar/pinnedSidebarThreads.ts +++ b/apps/app/src/components/sidebar/pinnedSidebarThreads.ts @@ -1,144 +1,2 @@ -import type { ThreadListEntry } from "@bb/domain"; -import { compareCodepoint } from "@/lib/codepoint-compare"; -import { - buildProjectThreadGroups, - compareStandardThreads, - type ProjectThreadItem, - type ProjectThreadNode, -} from "./projectThreadGroups"; - -interface PinnedSidebarState { - effectivePinnedThreadIds: Set; - rootNodes: ProjectThreadNode[]; -} - -interface BuildPinnedSidebarStateArgs { - draftThreadIds?: ReadonlySet; - threads: readonly ThreadListEntry[]; -} - -function compareByPinnedFallback( - left: ThreadListEntry, - right: ThreadListEntry, -): number { - const pinnedAtDelta = (right.pinnedAt ?? 0) - (left.pinnedAt ?? 0); - if (pinnedAtDelta !== 0) { - return pinnedAtDelta; - } - - const createdAtDelta = right.createdAt - left.createdAt; - if (createdAtDelta !== 0) { - return createdAtDelta; - } - - return compareCodepoint(left.id, right.id); -} - -function comparePinnedRoots( - left: ThreadListEntry, - right: ThreadListEntry, -): number { - if (left.pinSortKey !== null && right.pinSortKey !== null) { - const pinSortKeyDelta = compareCodepoint(left.pinSortKey, right.pinSortKey); - if (pinSortKeyDelta !== 0) { - return pinSortKeyDelta; - } - } - - return compareByPinnedFallback(left, right); -} - -function addDescendantThreadIds({ - childrenByParentId, - effectivePinnedThreadIds, - parentThreadId, - visitedThreadIds, -}: AddDescendantThreadIdsArgs): void { - if (visitedThreadIds.has(parentThreadId)) return; - - visitedThreadIds.add(parentThreadId); - for (const child of childrenByParentId.get(parentThreadId) ?? []) { - effectivePinnedThreadIds.add(child.id); - addDescendantThreadIds({ - childrenByParentId, - effectivePinnedThreadIds, - parentThreadId: child.id, - visitedThreadIds, - }); - } -} - -interface AddDescendantThreadIdsArgs { - childrenByParentId: ReadonlyMap; - effectivePinnedThreadIds: Set; - parentThreadId: string; - visitedThreadIds: Set; -} - -function collectRootNodes( - items: readonly ProjectThreadItem[], -): ProjectThreadNode[] { - return items.flatMap((item) => { - switch (item.kind) { - case "thread": - return [item.node]; - case "environment": - return item.group.nodes; - case "section": - // Pinned flattens before folding, so sections never reach here; recurse - // to keep the function total. - return collectRootNodes(item.group.items); - } - }); -} - -export function buildPinnedSidebarState({ - draftThreadIds = new Set(), - threads, -}: BuildPinnedSidebarStateArgs): PinnedSidebarState { - const explicitlyPinnedThreads = threads.filter( - (thread) => thread.pinnedAt !== null, - ); - const childrenByParentId = new Map(); - - for (const thread of threads) { - if (thread.parentThreadId === null) continue; - - const children = childrenByParentId.get(thread.parentThreadId); - if (children) { - children.push(thread); - } else { - childrenByParentId.set(thread.parentThreadId, [thread]); - } - } - - const effectivePinnedThreadIds = new Set( - explicitlyPinnedThreads.map((thread) => thread.id), - ); - for (const thread of explicitlyPinnedThreads) { - addDescendantThreadIds({ - childrenByParentId, - effectivePinnedThreadIds, - parentThreadId: thread.id, - visitedThreadIds: new Set(), - }); - } - - const effectivePinnedThreads = threads.filter((thread) => - effectivePinnedThreadIds.has(thread.id), - ); - const projectItems = buildProjectThreadGroups( - effectivePinnedThreads, - compareStandardThreads, - draftThreadIds, - ); - const rootNodes = collectRootNodes(projectItems); - rootNodes.sort((left, right) => - comparePinnedRoots(left.thread, right.thread), - ); - - return { - effectivePinnedThreadIds, - rootNodes, - }; -} +// Moved to @bb/client-core (shared with the native app); re-exported here so web imports keep resolving. +export { buildPinnedSidebarState } from "@bb/client-core"; diff --git a/apps/app/src/components/sidebar/projectThreadGroups.ts b/apps/app/src/components/sidebar/projectThreadGroups.ts index b5662f01a5..7c0f01ddbc 100644 --- a/apps/app/src/components/sidebar/projectThreadGroups.ts +++ b/apps/app/src/components/sidebar/projectThreadGroups.ts @@ -1,855 +1,27 @@ -import type { - EnvironmentWorkspaceDisplayKind, - ThreadListEntry, -} from "@bb/domain"; -import { compareCodepoint } from "@/lib/codepoint-compare"; -import { - getCollapsedChildActivity, - type CollapsedChildActivity, -} from "@/lib/thread-activity"; -import { buildSectionKey } from "./sectionKeys"; - -interface ProjectThreadNodeStats { - childCount: number; - childActivity: CollapsedChildActivity; -} - -export interface ProjectThreadNode { - thread: ThreadListEntry; - children: ProjectThreadItem[]; - depth: number; - stats: ProjectThreadNodeStats; -} - -type EnvironmentThreadGroupNodes = [ +// Moved to @bb/client-core (shared with the native app); re-exported here so web imports keep resolving. +export { + CHRONOLOGICAL_CONTAINER_ID, + compareByCreatedAtDescending, + compareStandardThreads, + getProjectThreadItemDescendants, + createSidebarProjectIdResolver, + resolveSidebarProjectId, + buildProjectThreadGroups, + buildChronologicalThreadList, + buildSectionThreadList, + isSidebarProjectThread, + getSidebarDndItemId, + countProjectThreadItemRows, + projectThreadItemContainsThread, + collectProjectThreadItemNavigationEntries, +} from "@bb/client-core"; +export type { ProjectThreadNode, - ProjectThreadNode, - ...ProjectThreadNode[], -]; - -export interface EnvironmentThreadGroup { - environmentId: string; - nodes: EnvironmentThreadGroupNodes; - stats: ProjectThreadNodeStats; -} - -export interface SidebarSectionDefinition { - id: string; - name: string; -} - -// A flat section node backed by a durable DB section row. -export interface SidebarSectionGroup { - id: string; - key: string; - name: string; - items: ProjectThreadItem[]; - threadCount: number; - activity: CollapsedChildActivity; -} - -// A single render slot in a thread sibling list. Threads and env groups -// interleave by recency, so renderers iterate one ordered list rather than two -// parallel arrays. Sections join the same list only under Group by: Section. -export type ProjectThreadItem = - | { kind: "thread"; node: ProjectThreadNode } - | { kind: "environment"; group: EnvironmentThreadGroup } - | { kind: "section"; group: SidebarSectionGroup }; - -// Container-id sentinel for the global section section. It namespaces persisted -// collapse keys and dnd ids from other sidebar rows. -export const CHRONOLOGICAL_CONTAINER_ID = "chronological"; - -// Orders sibling threads. The default keeps active rows pinned to createdAt and -// inactive rows on attention recency; chronological mode can swap in a literal -// createdAt comparator instead. -type ThreadItemComparator = ( - left: ProjectThreadItem, - right: ProjectThreadItem, -) => number; - -export type ThreadComparator = (( - left: ThreadListEntry, - right: ThreadListEntry, -) => number) & { - compareItems?: ThreadItemComparator; -}; - -type WorktreeDisplayKind = "managed-worktree" | "unmanaged-worktree"; -type SidebarProjectThreadShape = Pick< - ThreadListEntry, - "originKind" | "visibility" ->; - -interface BuildThreadNodeArgs { - ancestorThreadIds: ReadonlySet; - childrenByParentId: ReadonlyMap; - compareThreads: ThreadComparator; - depth: number; - draftThreadIds: ReadonlySet; - groupEnvironmentThreads: boolean; - thread: ThreadListEntry; - visitedThreadIds: Set; -} - -interface BucketWorktreeEnvironmentGroupsResult { - environmentThreadGroups: EnvironmentThreadGroup[]; - looseNodes: ProjectThreadNode[]; -} - -function isWorktreeDisplayKind( - kind: EnvironmentWorkspaceDisplayKind, -): kind is WorktreeDisplayKind { - return kind === "managed-worktree" || kind === "unmanaged-worktree"; -} - -export function compareByCreatedAtDescending( - left: ThreadListEntry, - right: ThreadListEntry, -): number { - const createdAtDelta = right.createdAt - left.createdAt; - if (createdAtDelta !== 0) { - return createdAtDelta; - } - - return compareCodepoint(left.id, right.id); -} - -function compareByLatestAttentionAtDescending( - left: ThreadListEntry, - right: ThreadListEntry, -): number { - const latestAttentionAtDelta = - right.latestAttentionAt - left.latestAttentionAt; - if (latestAttentionAtDelta !== 0) { - return latestAttentionAtDelta; - } - - return compareByCreatedAtDescending(left, right); -} - -export function compareStandardThreads( - left: ThreadListEntry, - right: ThreadListEntry, -): number { - // Use durable thread.status for the active bucket, not ephemeral runtime - // display state. Active rows stream frequent updates, so pin their position - // to createdAt; inactive rows use attention recency so read/archive metadata - // updates do not reshuffle the sidebar. - const leftIsActive = left.status === "active"; - const rightIsActive = right.status === "active"; - - if (leftIsActive !== rightIsActive) { - return leftIsActive ? -1 : 1; - } - - if (leftIsActive) { - return compareByCreatedAtDescending(left, right); - } - - return compareByLatestAttentionAtDescending(left, right); -} - -function representativeThread(item: ProjectThreadItem): ThreadListEntry { - switch (item.kind) { - case "thread": - return item.node.thread; - case "environment": - return item.group.nodes[0].thread; - case "section": - // Sections never reach this pre-bucket comparator path; fall back to the - // first nested item's representative so the function stays total. - return representativeThread(item.group.items[0]); - } -} - -function compareProjectThreadItems( - left: ProjectThreadItem, - right: ProjectThreadItem, - compareThreads: ThreadComparator, -): number { - return compareThreads( - representativeThread(left), - representativeThread(right), - ); -} - -function getNodeAndDescendantThreads( - node: ProjectThreadNode, -): ThreadListEntry[] { - return [node.thread, ...getProjectThreadItemDescendants(node.children)]; -} - -export function getProjectThreadItemDescendants( - items: readonly ProjectThreadItem[], -): ThreadListEntry[] { - return items.flatMap((item) => { - switch (item.kind) { - case "thread": - return getNodeAndDescendantThreads(item.node); - case "environment": - return item.group.nodes.flatMap(getNodeAndDescendantThreads); - case "section": - return getProjectThreadItemDescendants(item.group.items); - } - }); -} - -function buildStatsForHiddenThreads( - threads: readonly ThreadListEntry[], - draftThreadIds: ReadonlySet, -): ProjectThreadNodeStats { - return { - childCount: threads.length, - childActivity: getCollapsedChildActivity(threads, draftThreadIds), - }; -} - -function buildEnvironmentThreadGroup( - environmentId: string, - nodes: EnvironmentThreadGroupNodes, - draftThreadIds: ReadonlySet, -): EnvironmentThreadGroup { - const hiddenThreads = nodes.flatMap(getNodeAndDescendantThreads); - return { - environmentId, - nodes, - stats: buildStatsForHiddenThreads(hiddenThreads, draftThreadIds), - }; -} - -function buildThreadItem(node: ProjectThreadNode): ProjectThreadItem { - return { kind: "thread", node }; -} - -function buildEnvironmentItem( - group: EnvironmentThreadGroup, -): ProjectThreadItem { - return { kind: "environment", group }; -} - -function buildSortedItems( - nodes: ProjectThreadNode[], - compareThreads: ThreadComparator, - groupEnvironmentThreads: boolean, - draftThreadIds: ReadonlySet, -): ProjectThreadItem[] { - if (!groupEnvironmentThreads) { - nodes.sort((left, right) => compareThreads(left.thread, right.thread)); - return nodes.map(buildThreadItem); - } - - const { environmentThreadGroups, looseNodes } = - bucketWorktreeEnvironmentGroups(nodes, compareThreads, draftThreadIds); - const items = [ - ...looseNodes.map(buildThreadItem), - ...environmentThreadGroups.map(buildEnvironmentItem), - ]; - items.sort((left, right) => - compareProjectThreadItems(left, right, compareThreads), - ); - return items; -} - -function buildThreadNode({ - ancestorThreadIds, - childrenByParentId, - compareThreads, - depth, - draftThreadIds, - groupEnvironmentThreads, - thread, - visitedThreadIds, -}: BuildThreadNodeArgs): ProjectThreadNode { - visitedThreadIds.add(thread.id); - const nextAncestorThreadIds = new Set(ancestorThreadIds); - nextAncestorThreadIds.add(thread.id); - const childNodes: ProjectThreadNode[] = []; - - for (const childThread of childrenByParentId.get(thread.id) ?? []) { - if (nextAncestorThreadIds.has(childThread.id)) continue; - if (visitedThreadIds.has(childThread.id)) continue; - - childNodes.push( - buildThreadNode({ - ancestorThreadIds: nextAncestorThreadIds, - childrenByParentId, - compareThreads, - depth: depth + 1, - draftThreadIds, - groupEnvironmentThreads, - thread: childThread, - visitedThreadIds, - }), - ); - } - - const children = buildSortedItems( - childNodes, - compareThreads, - groupEnvironmentThreads, - draftThreadIds, - ); - return { - thread, - children, - depth, - stats: buildStatsForHiddenThreads( - getProjectThreadItemDescendants(children), - draftThreadIds, - ), - }; -} - -function isRootThread( - thread: ThreadListEntry, - projectThreadIds: ReadonlySet, -): boolean { - return ( - thread.parentThreadId === null || - !projectThreadIds.has(thread.parentThreadId) - ); -} - -/** - * Resolve the project group that shows a thread in "By project" mode. A child - * follows its root ancestor, so a child from another project stays nested under - * its parent instead of appearing as a root in its own project. When the parent - * chain is not in the list (archived, hidden, or a cycle), the thread falls - * back to its own project. - * - * The returned resolver memoizes per thread, so a sidebar pass over every - * thread walks each ancestor chain once instead of once per descendant. - */ -export function createSidebarProjectIdResolver( - threadById: ReadonlyMap, -): (thread: ThreadListEntry) => string { - const sidebarProjectIdByThreadId = new Map(); - return (thread) => { - const cached = sidebarProjectIdByThreadId.get(thread.id); - if (cached !== undefined) { - return cached; - } - const chain: ThreadListEntry[] = [thread]; - const visitedThreadIds = new Set([thread.id]); - let current = thread; - let resolved: string | undefined; - while (current.parentThreadId !== null) { - const parent = threadById.get(current.parentThreadId); - if (parent === undefined || visitedThreadIds.has(parent.id)) { - break; - } - const parentResolved = sidebarProjectIdByThreadId.get(parent.id); - if (parentResolved !== undefined) { - resolved = parentResolved; - break; - } - visitedThreadIds.add(parent.id); - chain.push(parent); - current = parent; - } - const sidebarProjectId = resolved ?? current.projectId; - for (const member of chain) { - sidebarProjectIdByThreadId.set(member.id, sidebarProjectId); - } - return sidebarProjectId; - }; -} - -export function resolveSidebarProjectId( - thread: ThreadListEntry, - threadById: ReadonlyMap, -): string { - return createSidebarProjectIdResolver(threadById)(thread); -} - -export function buildProjectThreadGroups( - allProjectThreads: readonly ThreadListEntry[], - compareThreads: ThreadComparator = compareStandardThreads, - draftThreadIds: ReadonlySet = new Set(), -): ProjectThreadItem[] { - // Project sections group worktree siblings into synthetic environment rows. - return buildThreadTreeItems( - allProjectThreads, - compareThreads, - true, - draftThreadIds, - ); -} - -function buildThreadTreeItems( - allThreads: readonly ThreadListEntry[], - compareThreads: ThreadComparator, - groupEnvironmentThreads: boolean, - draftThreadIds: ReadonlySet, -): ProjectThreadItem[] { - const projectThreads = allThreads.filter(isSidebarProjectThread); - const projectThreadIds = new Set(projectThreads.map((thread) => thread.id)); - const childrenByParentId = new Map(); - - for (const thread of projectThreads) { - if (thread.parentThreadId === null) continue; - if (!projectThreadIds.has(thread.parentThreadId)) continue; - - const children = childrenByParentId.get(thread.parentThreadId); - if (children) { - children.push(thread); - } else { - childrenByParentId.set(thread.parentThreadId, [thread]); - } - } - - const visitedThreadIds = new Set(); - const rootNodes: ProjectThreadNode[] = []; - - for (const thread of projectThreads) { - if (!isRootThread(thread, projectThreadIds)) continue; - if (visitedThreadIds.has(thread.id)) continue; - - rootNodes.push( - buildThreadNode({ - ancestorThreadIds: new Set(), - childrenByParentId, - compareThreads, - depth: 0, - draftThreadIds, - groupEnvironmentThreads, - thread, - visitedThreadIds, - }), - ); - } - - // Cycles have no natural root. Render any remaining cycle member once at the - // project root and cut the back-edge when the walk reaches an ancestor. - for (const thread of projectThreads) { - if (visitedThreadIds.has(thread.id)) continue; - - rootNodes.push( - buildThreadNode({ - ancestorThreadIds: new Set(), - childrenByParentId, - compareThreads, - depth: 0, - draftThreadIds, - groupEnvironmentThreads, - thread, - visitedThreadIds, - }), - ); - } - - return buildSortedItems( - rootNodes, - compareThreads, - groupEnvironmentThreads, - draftThreadIds, - ); -} - -// Chronological Threads bucket: root threads are globally ordered by the -// chosen comparator and descendants stay nested under their parent. Worktree -// grouping stays off. Side chats are excluded to match buildProjectThreadGroups. -export function buildChronologicalThreadList( - allThreads: readonly ThreadListEntry[], - compareThreads: ThreadComparator = compareStandardThreads, - draftThreadIds: ReadonlySet = new Set(), -): ProjectThreadItem[] { - return buildThreadTreeItems( - allThreads, - compareThreads, - false, - draftThreadIds, - ); -} - -// The global Sections view uses the chronological root tree, then buckets those -// roots by their durable section id. Descendants stay nested under their parent. -export function buildSectionThreadList( - allThreads: readonly ThreadListEntry[], - compareThreads: ThreadComparator = compareStandardThreads, - sections: readonly SidebarSectionDefinition[] = [], - draftThreadIds: ReadonlySet = new Set(), -): ProjectThreadItem[] { - return bucketIntoSections( - buildChronologicalThreadList(allThreads, compareThreads, draftThreadIds), - CHRONOLOGICAL_CONTAINER_ID, - compareThreads, - sections, - draftThreadIds, - ); -} - -export function isSidebarProjectThread( - thread: SidebarProjectThreadShape, -): boolean { - return thread.visibility !== "hidden"; -} - -// Bucket nodes by shared worktree environmentId. A bucket only becomes a group -// when >=2 sibling nodes share the environment; solo threads stay loose so we -// don't render degenerate 1-thread groups. -function bucketWorktreeEnvironmentGroups( - nodes: ProjectThreadNode[], - compareThreads: ThreadComparator, - draftThreadIds: ReadonlySet, -): BucketWorktreeEnvironmentGroupsResult { - const nodesByEnvironmentId = new Map(); - for (const node of nodes) { - if (node.thread.environmentId === null) continue; - if (!isWorktreeDisplayKind(node.thread.environmentWorkspaceDisplayKind)) { - continue; - } - const bucket = nodesByEnvironmentId.get(node.thread.environmentId); - if (bucket) { - bucket.push(node); - } else { - nodesByEnvironmentId.set(node.thread.environmentId, [node]); - } - } - - const groupedEnvironmentIds = new Set(); - const environmentThreadGroups: EnvironmentThreadGroup[] = []; - for (const [environmentId, bucket] of nodesByEnvironmentId) { - if (!hasAtLeastTwoThreadNodes(bucket)) continue; - bucket.sort((left, right) => compareThreads(left.thread, right.thread)); - groupedEnvironmentIds.add(environmentId); - environmentThreadGroups.push( - buildEnvironmentThreadGroup(environmentId, bucket, draftThreadIds), - ); - } - - const looseNodes = nodes.filter( - (node) => - node.thread.environmentId === null || - !groupedEnvironmentIds.has(node.thread.environmentId), - ); - looseNodes.sort((left, right) => compareThreads(left.thread, right.thread)); - - return { environmentThreadGroups, looseNodes }; -} - -function hasAtLeastTwoThreadNodes( - nodes: ProjectThreadNode[], -): nodes is EnvironmentThreadGroupNodes { - return nodes.length >= 2; -} - -// The thread that orders an item among its siblings. -function getItemOrderingThread( - item: ProjectThreadItem, - compareThreads: ThreadComparator, -): ThreadListEntry | null { - switch (item.kind) { - case "thread": - return item.node.thread; - case "environment": - return item.group.nodes[0].thread; - case "section": { - const descendants = getProjectThreadItemDescendants(item.group.items); - if (descendants.length === 0) { - return null; - } - return descendants.reduce((first, thread) => - compareThreads(thread, first) < 0 ? thread : first, - ); - } - } -} - -export function getSidebarDndItemId(item: ProjectThreadItem): string { - switch (item.kind) { - case "thread": - return item.node.thread.id; - case "environment": - return item.group.nodes[0].thread.id; - case "section": - return item.group.key; - } -} - -// Orders sections first, then each block by the active comparator. -function orderSiblingItems( - items: readonly ProjectThreadItem[], - compareThreads: ThreadComparator, -): ProjectThreadItem[] { - const decorated = items.map((item) => ({ - item, - isSection: item.kind === "section", - })); - decorated.sort((left, right) => { - if (left.isSection !== right.isSection) { - return left.isSection ? -1 : 1; - } - return compareSiblingItems(left.item, right.item, compareThreads); - }); - return decorated.map((entry) => entry.item); -} - -function getItemFallbackSortLabel(item: ProjectThreadItem): string { - switch (item.kind) { - case "thread": - return item.node.thread.id; - case "environment": - return item.group.environmentId; - case "section": - return item.group.name; - } -} - -function compareSiblingItems( - left: ProjectThreadItem, - right: ProjectThreadItem, - compareThreads: ThreadComparator, -): number { - if (compareThreads.compareItems) { - return compareThreads.compareItems(left, right); - } - - const leftThread = getItemOrderingThread(left, compareThreads); - const rightThread = getItemOrderingThread(right, compareThreads); - if (leftThread && rightThread) { - return compareThreads(leftThread, rightThread); - } - if (leftThread || rightThread) { - return leftThread ? -1 : 1; - } - return compareCodepoint( - getItemFallbackSortLabel(left), - getItemFallbackSortLabel(right), - ); -} - -function buildSectionGroup( - containerId: string, - section: SidebarSectionDefinition, - items: ProjectThreadItem[], - draftThreadIds: ReadonlySet, -): SidebarSectionGroup { - const descendantThreads = getProjectThreadItemDescendants(items); - return { - id: section.id, - key: buildSectionKey(containerId, section.id), - name: section.name, - items, - threadCount: descendantThreads.length, - activity: getCollapsedChildActivity(descendantThreads, draftThreadIds), - }; -} - -// Fold a top-level item list into flat DB-backed sections plus loose items. -function bucketIntoSections( - items: readonly ProjectThreadItem[], - containerId: string, - compareThreads: ThreadComparator = compareStandardThreads, - sections: readonly SidebarSectionDefinition[] = [], - draftThreadIds: ReadonlySet = new Set(), -): ProjectThreadItem[] { - const sectionDefinitionsById = new Map(); - const orderedSections: SidebarSectionDefinition[] = []; - for (const section of sections) { - if (sectionDefinitionsById.has(section.id)) { - continue; - } - sectionDefinitionsById.set(section.id, section); - orderedSections.push(section); - } - - const itemsBySectionId = new Map(); - for (const section of orderedSections) { - itemsBySectionId.set(section.id, []); - } - const looseItems: ProjectThreadItem[] = []; - - for (const item of items) { - const orderingThread = getItemOrderingThread(item, compareThreads); - const sectionId = orderingThread?.sectionId; - if (!sectionId) { - looseItems.push(item); - continue; - } - - let sectionItems = itemsBySectionId.get(sectionId); - if (!sectionItems) { - const fallbackSection = { id: sectionId, name: "Section" }; - sectionDefinitionsById.set(sectionId, fallbackSection); - orderedSections.push(fallbackSection); - sectionItems = []; - itemsBySectionId.set(sectionId, sectionItems); - } - sectionItems.push(item); - } - - const sectionItemsByName = orderedSections.map( - (section): ProjectThreadItem => { - const children = orderSiblingItems( - itemsBySectionId.get(section.id) ?? [], - compareThreads, - ); - return { - kind: "section", - group: buildSectionGroup( - containerId, - section, - children, - draftThreadIds, - ), - }; - }, - ); - const sectionItems = compareThreads.compareItems - ? orderSiblingItems(sectionItemsByName, compareThreads) - : sectionItemsByName; - const orderedLooseItems = orderSiblingItems(looseItems, compareThreads); - return [...sectionItems, ...orderedLooseItems]; -} - -export interface ProjectThreadItemRowCountContext { - collapsedThreadIds: ReadonlySet; - collapsedEnvironmentIds: ReadonlySet; - collapsedSectionKeys: ReadonlySet; -} - -function countThreadNodeRows( - node: ProjectThreadNode, - context: ProjectThreadItemRowCountContext, -): number { - if ( - node.children.length === 0 || - context.collapsedThreadIds.has(node.thread.id) - ) { - return 1; - } - return node.children.reduce( - (total, child) => total + countProjectThreadItemRows(child, context), - 1, - ); -} - -/** - * Count of the rows an item renders under the current collapse state. Drives - * placeholder-height estimates in the windowed sidebar thread list; exactness - * is not required because measured heights replace the estimate once an item - * has been on screen. - */ -export function countProjectThreadItemRows( - item: ProjectThreadItem, - context: ProjectThreadItemRowCountContext, -): number { - switch (item.kind) { - case "thread": - return countThreadNodeRows(item.node, context); - case "environment": - if (context.collapsedEnvironmentIds.has(item.group.environmentId)) { - return 1; - } - return item.group.nodes.reduce( - (total, node) => total + countThreadNodeRows(node, context), - 1, - ); - case "section": - if (context.collapsedSectionKeys.has(item.group.key)) { - return 1; - } - return item.group.items.reduce( - (total, child) => total + countProjectThreadItemRows(child, context), - 1, - ); - } -} - -/** True when the item's subtree renders a row for the given thread. */ -export function projectThreadItemContainsThread( - item: ProjectThreadItem, - threadId: string, -): boolean { - switch (item.kind) { - case "thread": - return ( - item.node.thread.id === threadId || - item.node.children.some((child) => - projectThreadItemContainsThread(child, threadId), - ) - ); - case "environment": - return item.group.nodes.some( - (node) => - node.thread.id === threadId || - node.children.some((child) => - projectThreadItemContainsThread(child, threadId), - ), - ); - case "section": - return item.group.items.some((child) => - projectThreadItemContainsThread(child, threadId), - ); - } -} - -export interface ProjectThreadItemNavigationEntry { - threadId: string; - projectId: string; -} - -function collectThreadNodeNavigationEntries( - node: ProjectThreadNode, - context: ProjectThreadItemRowCountContext, - entries: ProjectThreadItemNavigationEntry[], -): void { - entries.push({ - threadId: node.thread.id, - projectId: node.thread.projectId, - }); - if ( - node.children.length === 0 || - context.collapsedThreadIds.has(node.thread.id) - ) { - return; - } - for (const child of node.children) { - collectProjectThreadItemNavigationEntriesInto(child, context, entries); - } -} - -function collectProjectThreadItemNavigationEntriesInto( - item: ProjectThreadItem, - context: ProjectThreadItemRowCountContext, - entries: ProjectThreadItemNavigationEntry[], -): void { - switch (item.kind) { - case "thread": - collectThreadNodeNavigationEntries(item.node, context, entries); - return; - case "environment": - if (context.collapsedEnvironmentIds.has(item.group.environmentId)) { - return; - } - for (const node of item.group.nodes) { - collectThreadNodeNavigationEntries(node, context, entries); - } - return; - case "section": - if (context.collapsedSectionKeys.has(item.group.key)) { - return; - } - for (const child of item.group.items) { - collectProjectThreadItemNavigationEntriesInto(child, context, entries); - } - return; - } -} - -/** - * The threads an item's subtree renders, in visual order, respecting the - * current collapse state. Mirrors which rows would emit - * `data-sidebar-thread-shortcut-target` anchors when mounted, so a - * windowed-out placeholder can stand in for them during keyboard navigation. - */ -export function collectProjectThreadItemNavigationEntries( - item: ProjectThreadItem, - context: ProjectThreadItemRowCountContext, -): ProjectThreadItemNavigationEntry[] { - const entries: ProjectThreadItemNavigationEntry[] = []; - collectProjectThreadItemNavigationEntriesInto(item, context, entries); - return entries; -} + EnvironmentThreadGroup, + SidebarSectionDefinition, + SidebarSectionGroup, + ProjectThreadItem, + ThreadComparator, + ProjectThreadItemRowCountContext, + ProjectThreadItemNavigationEntry, +} from "@bb/client-core"; diff --git a/apps/app/src/components/sidebar/sectionKeys.ts b/apps/app/src/components/sidebar/sectionKeys.ts index 5611137ebd..fde95d2985 100644 --- a/apps/app/src/components/sidebar/sectionKeys.ts +++ b/apps/app/src/components/sidebar/sectionKeys.ts @@ -1,19 +1,2 @@ -// Pure helpers for sidebar section row identity. Section names are display text; -// membership lives in `thread.sectionId`. - -export function buildSectionKey( - containerId: string, - sectionId: string, -): string { - return `${containerId}::${sectionId}`; -} - -export function sectionKeyForThreadSection( - containerId: string, - sectionId: string | null | undefined, -): string | null { - if (!sectionId) { - return null; - } - return buildSectionKey(containerId, sectionId); -} +// Moved to @bb/client-core (shared with the native app); re-exported here so web imports keep resolving. +export { buildSectionKey, sectionKeyForThreadSection } from "@bb/client-core"; diff --git a/apps/app/src/components/sidebar/sidebarCollapsedAtoms.ts b/apps/app/src/components/sidebar/sidebarCollapsedAtoms.ts index 1bf7cc38e9..6f699bb80a 100644 --- a/apps/app/src/components/sidebar/sidebarCollapsedAtoms.ts +++ b/apps/app/src/components/sidebar/sidebarCollapsedAtoms.ts @@ -1,4 +1,5 @@ import { atomWithStorage } from "jotai/utils"; +import type { CollapsibleSidebarSectionId } from "@bb/client-core"; import { createJsonLocalStorage, type SyncStorage, @@ -23,13 +24,10 @@ const COLLAPSED_THREAD_SECTIONS_STORAGE_KEY = const LEGACY_COLLAPSED_FOLDERS_STORAGE_KEY = "bb.sidebar.collapsedFolders"; const COLLAPSED_MACHINES_STORAGE_KEY = "bb.sidebar.collapsedMachines"; -export type SidebarSectionId = - | "pinned" - | "threads" - | `project:${string}` - | `section:${string}` - | `machine:${string}`; -export type CollapsibleSidebarSectionId = "pinned" | "threads"; +export type { + CollapsibleSidebarSectionId, + SidebarSectionId, +} from "@bb/client-core"; // "project" keeps the per-project grouping; "chronological" is the persisted // value for the cross-project Sections view that replaced the old None view; diff --git a/apps/app/src/components/sidebar/sidebarSectionOrder.ts b/apps/app/src/components/sidebar/sidebarSectionOrder.ts index 219c6db6ad..19bad463ab 100644 --- a/apps/app/src/components/sidebar/sidebarSectionOrder.ts +++ b/apps/app/src/components/sidebar/sidebarSectionOrder.ts @@ -1,134 +1,11 @@ -import type { SidebarSectionId } from "./sidebarCollapsedAtoms"; -import { - applyNeighborReorder, - buildNeighborReorderRequest, -} from "@/lib/neighbor-reorder"; - -export type SidebarEntitySectionKind = "project" | "section" | "machine"; -export type LegacySidebarEntityAnchor = "projects" | "sections" | "machines"; - -export function buildSidebarEntitySectionId( - kind: SidebarEntitySectionKind, - id: string, -): SidebarSectionId { - return `${kind}:${id}`; -} - -export function isSidebarSectionId(value: string): value is SidebarSectionId { - return ( - value === "pinned" || - value === "threads" || - value.startsWith("project:") || - value.startsWith("section:") || - value.startsWith("machine:") - ); -} - -interface ReorderSidebarSectionOrderArgs { - activeId: string; - overId: string; - order: readonly SidebarSectionId[]; -} - -export function reorderSidebarSectionOrder({ - activeId, - overId, - order, -}: ReorderSidebarSectionOrderArgs): SidebarSectionId[] | null { - if (!isSidebarSectionId(activeId) || !isSidebarSectionId(overId)) { - return null; - } - const items = order.map((id) => ({ id })); - const request = buildNeighborReorderRequest({ activeId, overId, items }); - if (!request) return null; - return applyNeighborReorder({ items, request }) - .map((item) => item.id) - .filter(isSidebarSectionId); -} - -interface NormalizeSidebarSectionOrderArgs { - storedOrder: readonly string[]; - entitySectionIds: readonly SidebarSectionId[]; - legacyEntityAnchor: LegacySidebarEntityAnchor; - hasPinnedSection: boolean; - hasThreadsSection?: boolean; -} - -/** - * Reconciles locally persisted order with the live entity set. The old - * aggregate section token is expanded in place, so existing users keep their - * Pinned/primary/Threads layout when projects and sections become first-level - * sections. New entities join after the last entity without disturbing a - * user's explicit placement of built-in sections. - */ -export function normalizeSidebarSectionOrder({ - storedOrder, - entitySectionIds, - legacyEntityAnchor, - hasPinnedSection, - hasThreadsSection = true, -}: NormalizeSidebarSectionOrderArgs): SidebarSectionId[] { - const available = new Set([ - ...(hasPinnedSection ? (["pinned"] as const) : []), - ...entitySectionIds, - ...(hasThreadsSection ? (["threads"] as const) : []), - ]); - const entitySet = new Set(entitySectionIds); - const seen = new Set(); - const normalized: SidebarSectionId[] = []; - let expandedLegacyAnchor = false; - - const append = (sectionId: SidebarSectionId) => { - if (!available.has(sectionId) || seen.has(sectionId)) { - return; - } - seen.add(sectionId); - normalized.push(sectionId); - }; - - for (const storedId of storedOrder) { - if (storedId === legacyEntityAnchor) { - expandedLegacyAnchor = true; - for (const entityId of entitySectionIds) { - append(entityId); - } - continue; - } - if (isSidebarSectionId(storedId)) { - append(storedId); - } - } - - if (hasPinnedSection && !seen.has("pinned")) { - normalized.unshift("pinned"); - seen.add("pinned"); - } - - const missingEntities = entitySectionIds.filter( - (sectionId) => !seen.has(sectionId), - ); - if (missingEntities.length > 0) { - const lastEntityIndex = normalized.reduce( - (lastIndex, sectionId, index) => - entitySet.has(sectionId) ? index : lastIndex, - -1, - ); - const threadsIndex = normalized.indexOf("threads"); - const insertionIndex = - lastEntityIndex >= 0 - ? lastEntityIndex + 1 - : threadsIndex >= 0 || expandedLegacyAnchor - ? Math.max(threadsIndex, 0) - : normalized.length; - normalized.splice(insertionIndex, 0, ...missingEntities); - for (const sectionId of missingEntities) { - seen.add(sectionId); - } - } - - if (hasThreadsSection && !seen.has("threads")) { - normalized.push("threads"); - } - - return normalized; -} +// Moved to @bb/client-core (shared with the native app); re-exported here so web imports keep resolving. +export { + buildSidebarEntitySectionId, + isSidebarSectionId, + reorderSidebarSectionOrder, + normalizeSidebarSectionOrder, +} from "@bb/client-core"; +export type { + SidebarEntitySectionKind, + LegacySidebarEntityAnchor, +} from "@bb/client-core"; diff --git a/apps/app/src/components/sidebar/threadReadState.ts b/apps/app/src/components/sidebar/threadReadState.ts index b98c0a936a..8141fc6d36 100644 --- a/apps/app/src/components/sidebar/threadReadState.ts +++ b/apps/app/src/components/sidebar/threadReadState.ts @@ -1,10 +1,2 @@ -import type { Thread } from "@bb/domain"; -import { isThreadRead } from "@/lib/thread-read-state"; - -type ThreadReadToggleAction = "mark_read" | "mark_unread"; - -export function getThreadReadToggleAction( - thread: Pick, -): ThreadReadToggleAction { - return isThreadRead(thread) ? "mark_unread" : "mark_read"; -} +// Moved to @bb/client-core (shared with the native app); re-exported here so web imports keep resolving. +export { getThreadReadToggleAction } from "@bb/client-core"; diff --git a/apps/app/src/components/thread/terminal/terminal-websocket-transport.ts b/apps/app/src/components/thread/terminal/terminal-websocket-transport.ts index 01fe410908..84eae7bf2d 100644 --- a/apps/app/src/components/thread/terminal/terminal-websocket-transport.ts +++ b/apps/app/src/components/thread/terminal/terminal-websocket-transport.ts @@ -1,400 +1,8 @@ -import { getTerminalBase64DecodedByteLength } from "@bb/domain"; -import { - terminalServerMessageSchema, - type TerminalServerMessage, -} from "@bb/server-contract"; - -const SOCKET_OPEN = 1; -const DEFAULT_INPUT_QUEUE_MAX_BYTES = 1024 * 1024; -const DEFAULT_SOCKET_HIGH_WATER_BYTES = 1024 * 1024; -const DEFAULT_DRAIN_POLL_MS = 10; -const DEFAULT_HEARTBEAT_INTERVAL_MS = 15_000; -const DEFAULT_HEARTBEAT_TIMEOUT_MS = 45_000; -const DEFAULT_RECONNECT_DELAYS_MS = [100, 250, 500, 1_000, 2_000] as const; - -export type TerminalSocketConnectionState = - | "connecting" - | "open" - | "reconnecting" - | "closed"; - -export interface TerminalBrowserSocket { - bufferedAmount: number; - close(code?: number, reason?: string): void; - onclose: ((event: CloseEvent) => void) | null; - onerror: ((event: Event) => void) | null; - onmessage: ((event: MessageEvent) => void) | null; - onopen: ((event: Event) => void) | null; - readonly readyState: number; - send(data: string): void; -} - -export type CreateTerminalBrowserSocket = ( - url: string, -) => TerminalBrowserSocket; - -function terminalSocketUrlWithSinceSeq(url: string, sinceSeq: number): string { - const parsed = new URL(url); - parsed.searchParams.set("sinceSeq", String(sinceSeq)); - return parsed.toString(); -} - -interface PendingTerminalInput { - bytes: number; - payload: string; -} - -export interface TerminalWebSocketTransportOptions { - createSocket?: CreateTerminalBrowserSocket; - drainPollMs?: number; - heartbeatIntervalMs?: number; - heartbeatTimeoutMs?: number; - inputQueueMaxBytes?: number; - now?: () => number; - onConnectionState?: (state: TerminalSocketConnectionState) => void; - onInputOverflow?: (maxBytes: number) => void; - onInvalidMessage?: () => void; - onMessage: (message: TerminalServerMessage) => void; - onSequenceGap?: (expectedSeq: number, receivedSeq: number) => void; - reconnectDelaysMs?: readonly number[]; - shouldReconnect: () => boolean; - socketHighWaterBytes?: number; - url: string; -} - -export class TerminalWebSocketTransport { - private readonly createSocket: CreateTerminalBrowserSocket; - private readonly drainPollMs: number; - private readonly heartbeatIntervalMs: number; - private readonly heartbeatTimeoutMs: number; - private readonly inputQueueMaxBytes: number; - private readonly now: () => number; - private readonly reconnectDelaysMs: readonly number[]; - private readonly socketHighWaterBytes: number; - private drainTimeout: ReturnType | null = null; - private disposed = false; - private heartbeatInterval: ReturnType | null = null; - private lastPongAt = 0; - private lastResize: { cols: number; rows: number } | null = null; - private nextOutputSeq = 0; - private pendingInputBytes = 0; - private readonly pendingInputs: PendingTerminalInput[] = []; - private reconnectAttempt = 0; - private reconnectTimeout: ReturnType | null = null; - private socket: TerminalBrowserSocket | null = null; - private terminalEnded = false; - - constructor(private readonly options: TerminalWebSocketTransportOptions) { - this.createSocket = options.createSocket ?? ((url) => new WebSocket(url)); - this.drainPollMs = options.drainPollMs ?? DEFAULT_DRAIN_POLL_MS; - this.heartbeatIntervalMs = - options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS; - this.heartbeatTimeoutMs = - options.heartbeatTimeoutMs ?? DEFAULT_HEARTBEAT_TIMEOUT_MS; - this.inputQueueMaxBytes = - options.inputQueueMaxBytes ?? DEFAULT_INPUT_QUEUE_MAX_BYTES; - this.now = options.now ?? Date.now; - this.reconnectDelaysMs = - options.reconnectDelaysMs ?? DEFAULT_RECONNECT_DELAYS_MS; - this.socketHighWaterBytes = - options.socketHighWaterBytes ?? DEFAULT_SOCKET_HIGH_WATER_BYTES; - } - - start(): void { - if (this.disposed || this.socket !== null) { - return; - } - this.connect("connecting"); - } - - dispose(): void { - if (this.disposed) { - return; - } - this.disposed = true; - this.clearReconnectTimeout(); - this.clearDrainTimeout(); - this.stopHeartbeat(); - const socket = this.socket; - this.socket = null; - if (socket !== null) { - socket.onclose = null; - socket.onerror = null; - socket.onmessage = null; - socket.onopen = null; - socket.close(); - } - this.options.onConnectionState?.("closed"); - } - - sendInput(dataBase64: string): boolean { - const pending = { - bytes: getTerminalBase64DecodedByteLength(dataBase64), - payload: JSON.stringify({ - type: "input", - dataBase64, - }), - }; - const socket = this.socket; - if ( - socket !== null && - socket.readyState === SOCKET_OPEN && - socket.bufferedAmount <= this.socketHighWaterBytes && - this.pendingInputs.length === 0 - ) { - if (this.trySend(socket, pending.payload)) { - return true; - } - } - return this.enqueueInput(pending); - } - - sendResize(cols: number, rows: number): void { - if (this.lastResize?.cols === cols && this.lastResize.rows === rows) { - return; - } - this.lastResize = { cols, rows }; - const socket = this.socket; - if (socket === null || socket.readyState !== SOCKET_OPEN) { - return; - } - this.trySend( - socket, - JSON.stringify({ - type: "resize", - cols, - rows, - }), - ); - } - - private connect(state: "connecting" | "reconnecting"): void { - if (this.disposed || this.terminalEnded) { - return; - } - this.options.onConnectionState?.(state); - let socket: TerminalBrowserSocket; - try { - socket = this.createSocket( - terminalSocketUrlWithSinceSeq(this.options.url, this.nextOutputSeq), - ); - } catch { - this.scheduleReconnect(); - return; - } - this.socket = socket; - socket.onopen = () => this.handleOpen(socket); - socket.onmessage = (event) => this.handleMessage(socket, event.data); - socket.onerror = () => undefined; - socket.onclose = () => this.handleClose(socket); - } - - private handleOpen(socket: TerminalBrowserSocket): void { - if (this.disposed || this.socket !== socket) { - return; - } - this.reconnectAttempt = 0; - this.lastPongAt = this.now(); - this.options.onConnectionState?.("open"); - this.startHeartbeat(socket); - if (this.lastResize !== null) { - this.trySend( - socket, - JSON.stringify({ type: "resize", ...this.lastResize }), - ); - } - this.flushInputs(); - } - - private handleMessage(socket: TerminalBrowserSocket, raw: unknown): void { - if (this.disposed || this.socket !== socket || typeof raw !== "string") { - return; - } - let decoded: unknown; - try { - decoded = JSON.parse(raw); - } catch { - this.options.onInvalidMessage?.(); - return; - } - const parsed = terminalServerMessageSchema.safeParse(decoded); - if (!parsed.success) { - this.options.onInvalidMessage?.(); - return; - } - const message = parsed.data; - if (message.type === "pong") { - this.lastPongAt = this.now(); - } - if ( - message.type === "attached" && - message.replayStartSeq > this.nextOutputSeq - ) { - this.options.onSequenceGap?.(this.nextOutputSeq, message.replayStartSeq); - this.nextOutputSeq = message.replayStartSeq; - } - if (message.type === "output") { - if (message.chunk.seq < this.nextOutputSeq) { - return; - } - if (message.chunk.seq > this.nextOutputSeq) { - this.options.onSequenceGap?.(this.nextOutputSeq, message.chunk.seq); - } - this.nextOutputSeq = message.chunk.seq + 1; - } - if (message.type === "exited") { - this.terminalEnded = true; - this.clearReconnectTimeout(); - } - if ( - message.type === "error" && - [ - "terminal_exited", - "terminal_not_found", - "terminal_not_running", - ].includes(message.code) - ) { - this.terminalEnded = true; - this.clearReconnectTimeout(); - } - this.options.onMessage(message); - } - - private handleClose(socket: TerminalBrowserSocket): void { - if (this.socket !== socket) { - return; - } - this.socket = null; - this.stopHeartbeat(); - this.clearDrainTimeout(); - if ( - this.disposed || - this.terminalEnded || - !this.options.shouldReconnect() - ) { - this.options.onConnectionState?.("closed"); - return; - } - this.scheduleReconnect(); - } - - private scheduleReconnect(): void { - if ( - this.disposed || - this.terminalEnded || - this.reconnectTimeout !== null || - !this.options.shouldReconnect() - ) { - return; - } - this.options.onConnectionState?.("reconnecting"); - const delayIndex = Math.min( - this.reconnectAttempt, - this.reconnectDelaysMs.length - 1, - ); - const delay = this.reconnectDelaysMs[delayIndex] ?? 0; - this.reconnectAttempt += 1; - this.reconnectTimeout = setTimeout(() => { - this.reconnectTimeout = null; - this.connect("reconnecting"); - }, delay); - } - - private enqueueInput(pending: PendingTerminalInput): boolean { - if (this.pendingInputBytes + pending.bytes > this.inputQueueMaxBytes) { - this.options.onInputOverflow?.(this.inputQueueMaxBytes); - return false; - } - this.pendingInputs.push(pending); - this.pendingInputBytes += pending.bytes; - this.scheduleDrain(); - return true; - } - - private flushInputs(): void { - this.clearDrainTimeout(); - const socket = this.socket; - if (socket === null || socket.readyState !== SOCKET_OPEN) { - return; - } - while ( - this.pendingInputs.length > 0 && - socket.bufferedAmount <= this.socketHighWaterBytes - ) { - const pending = this.pendingInputs[0]; - if (!pending || !this.trySend(socket, pending.payload)) { - break; - } - this.pendingInputs.shift(); - this.pendingInputBytes -= pending.bytes; - } - if (this.pendingInputs.length > 0) { - this.scheduleDrain(); - } - } - - private trySend(socket: TerminalBrowserSocket, payload: string): boolean { - if (socket.readyState !== SOCKET_OPEN) { - return false; - } - try { - socket.send(payload); - return true; - } catch { - try { - socket.close(1011, "send-failed"); - } catch { - this.handleClose(socket); - } - return false; - } - } - - private scheduleDrain(): void { - if (this.drainTimeout !== null || this.socket?.readyState !== SOCKET_OPEN) { - return; - } - this.drainTimeout = setTimeout(() => { - this.drainTimeout = null; - this.flushInputs(); - }, this.drainPollMs); - } - - private clearDrainTimeout(): void { - if (this.drainTimeout === null) { - return; - } - clearTimeout(this.drainTimeout); - this.drainTimeout = null; - } - - private clearReconnectTimeout(): void { - if (this.reconnectTimeout === null) { - return; - } - clearTimeout(this.reconnectTimeout); - this.reconnectTimeout = null; - } - - private startHeartbeat(socket: TerminalBrowserSocket): void { - this.stopHeartbeat(); - this.heartbeatInterval = setInterval(() => { - if (this.socket !== socket || socket.readyState !== SOCKET_OPEN) { - return; - } - if (this.now() - this.lastPongAt > this.heartbeatTimeoutMs) { - socket.close(4000, "heartbeat-timeout"); - return; - } - this.trySend(socket, JSON.stringify({ type: "ping" })); - }, this.heartbeatIntervalMs); - } - - private stopHeartbeat(): void { - if (this.heartbeatInterval === null) { - return; - } - clearInterval(this.heartbeatInterval); - this.heartbeatInterval = null; - } -} +// Moved to @bb/client-core (shared with the native app); re-exported here so web imports keep resolving. +export { TerminalWebSocketTransport } from "@bb/client-core"; +export type { + TerminalSocketConnectionState, + TerminalBrowserSocket, + CreateTerminalBrowserSocket, + TerminalWebSocketTransportOptions, +} from "@bb/client-core"; diff --git a/apps/app/src/components/thread/terminal/terminal-websocket-url.ts b/apps/app/src/components/thread/terminal/terminal-websocket-url.ts index 27f9111b9f..691aabae58 100644 --- a/apps/app/src/components/thread/terminal/terminal-websocket-url.ts +++ b/apps/app/src/components/thread/terminal/terminal-websocket-url.ts @@ -1,14 +1,10 @@ +import { + buildTerminalWebSocketPath, + type BuildTerminalWebSocketPathArgs, +} from "@bb/client-core"; import { buildDevWebSocketUrl } from "@/lib/dev-websocket-url"; -interface BuildTerminalWebSocketUrlArgs { - terminalId: string; -} - -function buildTerminalWebSocketPath({ - terminalId, -}: BuildTerminalWebSocketUrlArgs): string { - return `/ws/terminals/${encodeURIComponent(terminalId)}`; -} +type BuildTerminalWebSocketUrlArgs = BuildTerminalWebSocketPathArgs; function buildWebSocketUrl(path: string): string { const devWebSocketUrl = buildDevWebSocketUrl({ path }); diff --git a/apps/app/src/components/thread/timeline/TimelineFileDiffBlock.tsx b/apps/app/src/components/thread/timeline/TimelineFileDiffBlock.tsx index 833232f103..73162cabed 100644 --- a/apps/app/src/components/thread/timeline/TimelineFileDiffBlock.tsx +++ b/apps/app/src/components/thread/timeline/TimelineFileDiffBlock.tsx @@ -2,10 +2,10 @@ import { memo, useMemo } from "react"; import { parsePatchFiles, type FileDiffMetadata } from "@pierre/diffs"; import type { TimelineFileChange } from "@bb/server-contract"; import { - getFileChangeAction, - isPatchMetadataLine, - type FileChangeAction, -} from "@bb/thread-view"; + getPlainDiffFallback, + getRenderablePatchText, + type RenderablePatchText, +} from "@bb/client-core"; import { GitDiffCard } from "../../git-diff/GitDiffCard.js"; import { EventCodeBlock } from "../../ui/event-code-block.js"; import { TimelineDetailScroll } from "./TimelineDetailScroll.js"; @@ -29,18 +29,11 @@ interface RenderablePatch { fileDiff: FileDiffMetadata; } -interface RenderablePatchText { - disableLineNumbers: boolean; - patch: string; -} - interface RenderedFileChange { plainDiff: string | null; renderablePatch: RenderablePatch | null; } -type SyntheticPatchAction = "created" | "deleted"; - const DIFF_VIEW_BASE_OPTIONS = { overflow: "scroll", diffStyle: "unified", @@ -51,133 +44,6 @@ const renderedFileChangeCache = new WeakMap< RenderedFileChange >(); -function splitPatchLines(diff: string): string[] { - const normalizedDiff = diff.replaceAll("\r\n", "\n"); - if (normalizedDiff.length === 0) { - return []; - } - const lines = normalizedDiff.split("\n"); - const lastLine = lines[lines.length - 1]; - if (lastLine === "") { - lines.pop(); - } - return lines; -} - -function getPatchBodyLines(diff: string | null): string[] { - if (!diff) { - return []; - } - return splitPatchLines(diff).filter((line) => !isPatchMetadataLine(line)); -} - -function normalizePatchPath(path: string): string { - return path.replaceAll("\\", "/").replace(/^\/+/u, ""); -} - -function buildSyntheticPatchBodyLines( - lines: readonly string[], - action: SyntheticPatchAction, -): string[] { - const contentPrefix = action === "created" ? "+" : "-"; - const oppositePrefix = action === "created" ? "-" : "+"; - const bodyLines: string[] = []; - - for (const line of lines) { - if (line.startsWith(contentPrefix)) { - bodyLines.push(line); - continue; - } - if (line.startsWith(oppositePrefix) || line.startsWith(" ")) { - continue; - } - bodyLines.push(`${contentPrefix}${line}`); - } - - return bodyLines; -} - -function toSyntheticPatch( - change: TimelineFileChange, - action: SyntheticPatchAction, -): string | null { - const lines = getPatchBodyLines(change.diff); - if (lines.length === 0) return null; - const normalizedPath = normalizePatchPath(change.path); - const fromPath = action === "created" ? "/dev/null" : `a/${normalizedPath}`; - const toPath = action === "created" ? `b/${normalizedPath}` : "/dev/null"; - const bodyLines = buildSyntheticPatchBodyLines(lines, action); - if (bodyLines.length === 0) return null; - const oldCount = action === "created" ? 0 : bodyLines.length; - const newCount = action === "created" ? bodyLines.length : 0; - const body = bodyLines.join("\n"); - return `diff --git a/${normalizedPath} b/${normalizedPath}\n--- ${fromPath}\n+++ ${toPath}\n@@ -1,${oldCount} +1,${newCount} @@\n${body}\n`; -} - -function toSyntheticUpdatePatch(change: TimelineFileChange): string | null { - const bodyLines = getPatchBodyLines(change.diff); - if (bodyLines.length === 0) { - return null; - } - const hasUnifiedLines = bodyLines.some( - (line) => line.startsWith("+") || line.startsWith("-"), - ); - if (!hasUnifiedLines) { - return null; - } - - const normalizedPath = normalizePatchPath(change.movePath ?? change.path); - const removedCount = bodyLines.filter((line) => line.startsWith("-")).length; - const addedCount = bodyLines.filter((line) => line.startsWith("+")).length; - return `diff --git a/${normalizedPath} b/${normalizedPath}\n--- a/${normalizedPath}\n+++ b/${normalizedPath}\n@@ -1,${Math.max(removedCount, 1)} +1,${Math.max(addedCount, 1)} @@\n${bodyLines.join("\n")}\n`; -} - -function getRenderablePatchText( - change: TimelineFileChange, -): RenderablePatchText | null { - const patch = change.diff; - if (patch && patch.trim().length > 0) { - const trimmedPatch = patch.trimEnd(); - if ( - trimmedPatch.startsWith("diff --git") || - (trimmedPatch.includes("--- ") && - trimmedPatch.includes("+++ ") && - trimmedPatch.includes("@@")) - ) { - return { - patch, - disableLineNumbers: false, - }; - } - if (patch.includes("@@")) { - const normalizedPath = normalizePatchPath(change.movePath ?? change.path); - return { - // The leading `diff --git` line is what flips parsePatchFiles into - // git-aware mode — without it, the parser keeps the `a/` and `b/` - // prefixes on the file headers and the card thinks the file was - // renamed (prevName="a/foo", name="b/foo"). - patch: `diff --git a/${normalizedPath} b/${normalizedPath}\n--- a/${normalizedPath}\n+++ b/${normalizedPath}\n${patch.trimEnd()}\n`, - disableLineNumbers: false, - }; - } - } - - const action: FileChangeAction = getFileChangeAction(change); - const syntheticPatch = - (action === "created" - ? toSyntheticPatch(change, "created") - : action === "deleted" - ? toSyntheticPatch(change, "deleted") - : null) ?? toSyntheticUpdatePatch(change); - if (!syntheticPatch) { - return null; - } - return { - patch: syntheticPatch, - disableLineNumbers: true, - }; -} - function parseRenderablePatch( patchText: RenderablePatchText, ): RenderablePatch | null { @@ -203,17 +69,6 @@ function parseRenderablePatch( } } -function getPlainDiffFallback( - change: TimelineFileChange, - hasRenderablePatch: boolean, -): string | null { - if (hasRenderablePatch) { - return null; - } - const diff = change.diff?.trimEnd(); - return diff && diff.length > 0 ? diff : null; -} - function buildRenderedFileChange( change: TimelineFileChange, ): RenderedFileChange { diff --git a/apps/app/src/components/thread/timeline/compute-muted-prefix-length.ts b/apps/app/src/components/thread/timeline/compute-muted-prefix-length.ts index 185980b707..9a6ec3d2ea 100644 --- a/apps/app/src/components/thread/timeline/compute-muted-prefix-length.ts +++ b/apps/app/src/components/thread/timeline/compute-muted-prefix-length.ts @@ -1,28 +1,2 @@ -import type { TimelineUserConversationRow } from "@bb/server-contract"; - -/** - * Detect the closing bracket of a `[bb …]` prefix on non-user messages so the - * renderer can split generated-message chrome from the user-readable body. We - * never extract data from the prefix — only locate its boundary based on the - * leading `[bb` marker. Trailing whitespace after `]` is absorbed into the - * prefix region so block (`\n\n`) and inline (` `) writer-side separators - * render identically: header on one line, body directly below, with no blank - * gap. - * - * Returns the index in `text` where the body begins. `0` means "no muted - * prefix" — render the text plain. - */ -export function computeMutedPrefixLength( - initiator: TimelineUserConversationRow["initiator"], - text: string, -): number { - if (initiator === "user") return 0; - if (!text.startsWith("[bb")) return 0; - const closeIdx = text.indexOf("]"); - if (closeIdx === -1) return 0; - let endIdx = closeIdx + 1; - while (endIdx < text.length && /\s/.test(text.charAt(endIdx))) { - endIdx += 1; - } - return endIdx; -} +// Moved to @bb/client-core (shared with the native app); re-exported here so web imports keep resolving. +export { computeMutedPrefixLength } from "@bb/client-core"; diff --git a/apps/app/src/components/thread/timeline/conversation-message-limits.ts b/apps/app/src/components/thread/timeline/conversation-message-limits.ts index 9daa104a56..ff2b573bc4 100644 --- a/apps/app/src/components/thread/timeline/conversation-message-limits.ts +++ b/apps/app/src/components/thread/timeline/conversation-message-limits.ts @@ -1,107 +1,9 @@ -import { isRawThreadId } from "@bb/domain"; - -export const USER_MESSAGE_CHAR_CAP = 4096; - -// Generated rows are collapsed by default, so keep their initial Markdown -// parse under the same bounded budget as collapsed authored messages. -export const GENERATED_MESSAGE_COLLAPSED_PREVIEW_CHAR_CAP = - USER_MESSAGE_CHAR_CAP; - -export interface BoundedMarkdownPreview { - parseAsMarkdown: boolean; - text: string; - wasCapped: boolean; -} - -function isWhitespace(value: string | undefined): boolean { - return value !== undefined && /\s/u.test(value); -} - -export function endsInsideExactRawThreadIdCodeSpan(text: string): boolean { - let openDelimiterLength = 0; - let openContentStart = -1; - for (let index = 0; index < text.length; index++) { - if (text[index] !== "`" || isEscapedBacktick(text, index)) continue; - let delimiterEnd = index + 1; - while (text[delimiterEnd] === "`") delimiterEnd += 1; - const delimiterLength = delimiterEnd - index; - if (openDelimiterLength === 0) { - openDelimiterLength = delimiterLength; - openContentStart = delimiterEnd; - } else if (delimiterLength === openDelimiterLength) { - openDelimiterLength = 0; - openContentStart = -1; - } - index = delimiterEnd - 1; - } - return ( - openDelimiterLength > 0 && - openContentStart >= 0 && - isRawThreadId(text.slice(openContentStart)) - ); -} - -function cappedMarkdownPreview(text: string): BoundedMarkdownPreview { - return { - parseAsMarkdown: !endsInsideExactRawThreadIdCodeSpan(text), - text, - wasCapped: true, - }; -} - -/** - * Bounds Markdown before parsing without manufacturing a complete token at the - * cut. If the cap bisects a token, retreat to whitespace; a single unbroken - * token stays plain text until the user explicitly expands it. - */ -export function boundedMarkdownPreview( - text: string, - cap: number, -): BoundedMarkdownPreview { - if (text.length <= cap) { - return { parseAsMarkdown: true, text, wasCapped: false }; - } - - const previewWindow = text.slice(0, cap + 1); - const cappedText = previewWindow.slice(0, cap); - const capSplitsToken = - !isWhitespace(cappedText.at(-1)) && !isWhitespace(previewWindow[cap]); - if (!capSplitsToken) { - return cappedMarkdownPreview(cappedText); - } - - const lastWhitespaceIndex = cappedText.search(/\s(?=\S*$)/u); - if (lastWhitespaceIndex < 0) { - return { parseAsMarkdown: false, text: cappedText, wasCapped: true }; - } - - return cappedMarkdownPreview(cappedText.slice(0, lastWhitespaceIndex + 1)); -} - -function isEscapedBacktick(text: string, index: number): boolean { - let slashCount = 0; - for (let cursor = index - 1; cursor >= 0 && text[cursor] === "\\"; cursor--) { - slashCount += 1; - } - return slashCount % 2 === 1; -} - -/** Closes a code span cut by a preview cap without adding visible text. */ -export function closeUnterminatedMarkdownCodeSpan(text: string): string { - let openDelimiterLength = 0; - for (let index = 0; index < text.length; index++) { - if (text[index] !== "`" || isEscapedBacktick(text, index)) continue; - let delimiterEnd = index + 1; - while (text[delimiterEnd] === "`") delimiterEnd += 1; - const delimiterLength = delimiterEnd - index; - if (openDelimiterLength === 0) { - openDelimiterLength = delimiterLength; - } else if (delimiterLength === openDelimiterLength) { - openDelimiterLength = 0; - } - index = delimiterEnd - 1; - } - return openDelimiterLength === 0 - ? text - : `${text}${"`".repeat(openDelimiterLength)}`; -} +// Moved to @bb/client-core (shared with the native app); re-exported here so web imports keep resolving. +export { + USER_MESSAGE_CHAR_CAP, + GENERATED_MESSAGE_COLLAPSED_PREVIEW_CHAR_CAP, + endsInsideExactRawThreadIdCodeSpan, + boundedMarkdownPreview, + closeUnterminatedMarkdownCodeSpan, +} from "@bb/client-core"; +export type { BoundedMarkdownPreview } from "@bb/client-core"; diff --git a/apps/app/src/components/thread/timeline/conversation-turn-request-label.ts b/apps/app/src/components/thread/timeline/conversation-turn-request-label.ts index 5ca93bedfa..896f594dc7 100644 --- a/apps/app/src/components/thread/timeline/conversation-turn-request-label.ts +++ b/apps/app/src/components/thread/timeline/conversation-turn-request-label.ts @@ -1,12 +1,2 @@ -import type { TimelineConversationTurnRequest } from "@bb/server-contract"; - -export function turnRequestLabel( - turnRequest: TimelineConversationTurnRequest, -): string | null { - if (turnRequest.kind !== "steer") { - return null; - } - if (turnRequest.status === "pending") return "Steer pending"; - if (turnRequest.status === "rejected") return "Steer failed"; - return "Steer"; -} +// Moved to @bb/client-core (shared with the native app); re-exported here so web imports keep resolving. +export { turnRequestLabel } from "@bb/client-core"; diff --git a/apps/app/src/components/thread/timeline/thread-runtime-status.ts b/apps/app/src/components/thread/timeline/thread-runtime-status.ts index bc2ac32283..ad0db00cda 100644 --- a/apps/app/src/components/thread/timeline/thread-runtime-status.ts +++ b/apps/app/src/components/thread/timeline/thread-runtime-status.ts @@ -1,21 +1,2 @@ -import { assertNever } from "@bb/core-ui"; -import type { ThreadRuntimeDisplayStatus } from "@bb/domain"; - -export function isRunningThreadRuntimeDisplayStatus( - status: ThreadRuntimeDisplayStatus, -): boolean { - switch (status) { - case "active": - case "host-reconnecting": - case "provisioning": - case "starting": - case "stopping": - return true; - case "error": - case "idle": - case "waiting-for-host": - return false; - default: - return assertNever(status); - } -} +// Moved to @bb/client-core (shared with the native app); re-exported here so web imports keep resolving. +export { isRunningThreadRuntimeDisplayStatus } from "@bb/client-core"; diff --git a/apps/app/src/components/thread/timeline/timeline-auto-expand.ts b/apps/app/src/components/thread/timeline/timeline-auto-expand.ts index c2db72cf5c..fabd62b4e3 100644 --- a/apps/app/src/components/thread/timeline/timeline-auto-expand.ts +++ b/apps/app/src/components/thread/timeline/timeline-auto-expand.ts @@ -1,194 +1,8 @@ -import { - assertNever, - findTimelineFrontierRow, - hasTimelineExplorationIntent, - type ThreadTimelineViewRow, - type TimelineViewWorkRow, -} from "@bb/thread-view"; - -interface CollectTimelineAutoExpansionRowIdsArgs { - rows: readonly ThreadTimelineViewRow[]; - scopeActive: boolean; -} - -export interface TimelineAutoExpansionRowIds { - liveFrontierRowIds: ReadonlySet; - terminalFrontierRowIds: ReadonlySet; -} - -export function isWorkRowExpandable(row: TimelineViewWorkRow): boolean { - switch (row.workKind) { - case "web-search": - case "web-fetch": - case "approval": - return false; - case "image-view": - return true; - case "question": - // Resolving and answered rows both carry a recorded answer in their - // body. Pending/interrupted stay title-only. Matches the - // body-collapse rule in QuestionWorkRowBody. - return row.lifecycle === "answered" || row.lifecycle === "resolving"; - case "command": - case "tool": - return !hasTimelineExplorationIntent(row); - case "file-change": - return true; - case "delegation": - return row.childRows.length > 0 || row.output.trim().length > 0; - case "workflow": - // The phase/agent tree (or terminal summary/error) lives in the body; a - // degraded row with none of them stays title-only. Matches the - // body-collapse rule in WorkflowWorkRowBody. - return ( - row.workflow !== null || row.summary !== null || row.error !== null - ); - default: - return assertNever(row); - } -} - -export function isRowExpandable(row: ThreadTimelineViewRow): boolean { - switch (row.kind) { - case "conversation": - return false; - case "system": - return row.detail !== null && row.detail.trim().length > 0; - case "bundle-summary": - case "step-summary": - return row.children.length > 0; - case "turn": - return true; - case "work": - return isWorkRowExpandable(row); - default: - return assertNever(row); - } -} - -/** - * Bundle and step summaries whose children are all non-expandable get the - * base max-height cap with overflow fades. Summaries that contain any - * expandable child do not — capping then would put the child's own scroll - * body inside a scrolling parent, which is poor UX. The expandability test - * reuses `isWorkRowExpandable` so the cap rule and the per-row expand - * affordance can never disagree. - */ -export function isNonExpandableSummary( - children: readonly TimelineViewWorkRow[], -): boolean { - return ( - children.length > 0 && - children.every((child) => !isWorkRowExpandable(child)) - ); -} - -function shouldAutoExpandLiveFrontierRow(row: ThreadTimelineViewRow): boolean { - if (!isRowExpandable(row)) { - return false; - } - switch (row.kind) { - case "system": - return row.status === "pending"; - case "bundle-summary": - return true; - case "work": - return ( - row.workKind === "delegation" || - row.workKind === "image-view" || - // A running workflow auto-opens so live agent progress is visible. - (row.workKind === "workflow" && row.status === "pending") - ); - case "conversation": - case "step-summary": - case "turn": - return false; - default: - return assertNever(row); - } -} - -function shouldAutoExpandTerminalFrontierRow( - row: ThreadTimelineViewRow, -): boolean { - return ( - isRowExpandable(row) && row.kind === "system" && row.status === "error" - ); -} - -function visitForTerminalFrontierAutoExpand( - rows: readonly ThreadTimelineViewRow[], - ids: Set, -): void { - const tail = rows[rows.length - 1]; - if (tail && shouldAutoExpandTerminalFrontierRow(tail)) { - ids.add(tail.id); - } - - for (const row of rows) { - if ( - row.kind === "work" && - row.workKind === "delegation" && - row.status === "pending" - ) { - visitForTerminalFrontierAutoExpand(row.childRows, ids); - } - } -} - -// Auto-expand rule: -// -// 1. Terminal frontier: the literal tail row in a scope. Selected terminal -// rows, currently system errors with detail, open when they arrive. The -// terminal pass descends into pending delegation childRows as nested -// scopes. The row component preserves that visible disclosure state after -// later appends; the collector does not keep old terminal rows -// auto-expanded. -// -// 2. Live frontier: only while the scope is active, find the trailing row -// that the agent produced (skipping user input rows). Selected live rows -// open while they are the current active frontier, then stop being -// auto-expanded when newer agent/system/work output supersedes them. -// -// Active containers are the timeline's top-level row list (when the thread -// is active) and the childRows of pending delegations *inside an active -// container*. A completed delegation closes its scope, so a pending -// sub-delegation buried inside a completed parent does NOT auto-expand — -// the active scope must propagate from the top-level thread runtime down -// through every enclosing container. -function visitForLiveFrontierAutoExpand( - rows: readonly ThreadTimelineViewRow[], - scopeActive: boolean, - ids: Set, -): void { - if (!scopeActive) { - return; - } - const frontier = findTimelineFrontierRow(rows); - if (frontier && shouldAutoExpandLiveFrontierRow(frontier)) { - ids.add(frontier.id); - } - for (const row of rows) { - if ( - row.kind === "work" && - row.workKind === "delegation" && - row.status === "pending" - ) { - visitForLiveFrontierAutoExpand(row.childRows, true, ids); - } - } -} - -export function collectTimelineAutoExpansionRowIds({ - rows, - scopeActive, -}: CollectTimelineAutoExpansionRowIdsArgs): TimelineAutoExpansionRowIds { - const terminalFrontierRowIds = new Set(); - const liveFrontierRowIds = new Set(); - visitForTerminalFrontierAutoExpand(rows, terminalFrontierRowIds); - visitForLiveFrontierAutoExpand(rows, scopeActive, liveFrontierRowIds); - return { - liveFrontierRowIds, - terminalFrontierRowIds, - }; -} +// Moved to @bb/client-core (shared with the native app); re-exported here so web imports keep resolving. +export { + isWorkRowExpandable, + isRowExpandable, + isNonExpandableSummary, + collectTimelineAutoExpansionRowIds, +} from "@bb/client-core"; +export type { TimelineAutoExpansionRowIds } from "@bb/client-core"; diff --git a/apps/app/src/components/thread/timeline/timelineRowSignatures.ts b/apps/app/src/components/thread/timeline/timelineRowSignatures.ts index 733179215c..800eb82a21 100644 --- a/apps/app/src/components/thread/timeline/timelineRowSignatures.ts +++ b/apps/app/src/components/thread/timeline/timelineRowSignatures.ts @@ -1,330 +1,6 @@ -import type { TimelineActivityIntent } from "@bb/server-contract"; -import { - assertNever, - type ThreadTimelineViewRow, - type TimelineViewWorkRow, -} from "@bb/thread-view"; - -type TimelineRowSignaturePart = boolean | number | string | null | undefined; - -function signaturePart(value: TimelineRowSignaturePart): string { - if (value === null) return ""; - if (value === undefined) return ""; - return String(value); -} - -export function joinSignatureParts( - parts: readonly TimelineRowSignaturePart[], -): string { - return parts.map(signaturePart).join("\u001f"); -} - -function activityIntentSignature(intent: TimelineActivityIntent): string { - switch (intent.type) { - case "read": - return joinSignatureParts([ - intent.type, - intent.command, - intent.name, - intent.path, - ]); - case "list_files": - return joinSignatureParts([intent.type, intent.command, intent.path]); - case "search": - return joinSignatureParts([ - intent.type, - intent.command, - intent.query, - intent.path, - ]); - case "unknown": - return joinSignatureParts([intent.type, intent.command]); - default: - return assertNever(intent); - } -} - -function activityIntentsSignature( - intents: readonly TimelineActivityIntent[], -): string { - return intents.map(activityIntentSignature).join("\u001e"); -} - -// View rows are immutable — `useTimelineViewRowsCache` preserves row identity -// across renders for unchanged data — so signature computation is safe to -// memoize by row reference. A miss is the streaming-update path (new row); -// a hit covers all cross-render reuse, including duplicate invocations from -// `areTimelineRowViewPropsEqual`, `useTimelineRowTitleRenderState`, and -// `TimelineExpandableBody`'s `contentKey`. -const rowSignatureCache = new WeakMap(); -const rowsSignatureCache = new WeakMap< - readonly ThreadTimelineViewRow[], - string ->(); - -export function timelineRowsSignature( - rows: readonly ThreadTimelineViewRow[], -): string { - const cached = rowsSignatureCache.get(rows); - if (cached !== undefined) return cached; - const signature = rows.map(timelineRowRenderSignature).join("\u001e"); - rowsSignatureCache.set(rows, signature); - return signature; -} - -function timelineRowBaseSignature(row: ThreadTimelineViewRow): string { - // sourceSeqEnd guards high-mutation fields omitted from signatures below, - // including output, text, and diffs. In-place row content mutations must - // advance the source sequence to avoid stale memoized UI. - return joinSignatureParts([ - row.kind, - row.id, - row.threadId, - row.turnId, - row.sourceSeqStart, - row.sourceSeqEnd, - row.startedAt, - row.createdAt, - ]); -} - -function timelineWorkRowRenderSignature(row: TimelineViewWorkRow): string { - const baseParts: TimelineRowSignaturePart[] = [ - timelineRowBaseSignature(row), - row.status, - row.workKind, - row.inClosedStep, - ]; - - switch (row.workKind) { - case "command": - return joinSignatureParts([ - ...baseParts, - row.callId, - row.command, - row.source, - row.exitCode, - row.completedAt, - row.approvalStatus, - activityIntentsSignature(row.activityIntents), - ]); - case "tool": - return joinSignatureParts([ - ...baseParts, - row.callId, - row.toolName, - row.completedAt, - row.approvalStatus, - activityIntentsSignature(row.activityIntents), - ]); - case "file-change": - return joinSignatureParts([ - ...baseParts, - row.callId, - row.approvalStatus, - row.change.kind, - row.change.path, - row.change.movePath, - row.change.diffStats.added, - row.change.diffStats.removed, - ]); - case "web-search": - return joinSignatureParts([ - ...baseParts, - row.callId, - row.queries.join("\u001e"), - row.completedAt, - ]); - case "web-fetch": - return joinSignatureParts([ - ...baseParts, - row.callId, - row.url, - row.prompt, - row.pattern, - row.completedAt, - ]); - case "image-view": - return joinSignatureParts([ - ...baseParts, - row.callId, - row.path, - row.completedAt, - ]); - case "delegation": - return joinSignatureParts([ - ...baseParts, - row.callId, - row.toolName, - row.subagentType, - row.description, - row.completedAt, - timelineRowsSignature(row.childRows), - ]); - case "workflow": - return joinSignatureParts([ - ...baseParts, - row.itemId, - row.taskType, - row.taskStatus, - row.workflowName, - row.description, - row.completedAt, - row.summary, - row.error, - row.usage?.totalTokens ?? null, - // Every progress-mutated agent field must break memo equality. - row.workflow - ? row.workflow.agents - .map((agent) => - joinSignatureParts([ - agent.index, - agent.label, - agent.state, - agent.attempt, - agent.tokens ?? null, - agent.toolCalls ?? null, - agent.durationMs ?? null, - agent.lastProgressAt, - agent.error ?? null, - ]), - ) - .join("\u001e") - : null, - row.workflow - ? row.workflow.phases - .map((phase) => - joinSignatureParts([phase.index, phase.title, phase.kind ?? null]), - ) - .join("\u001e") - : null, - ]); - case "approval": - return joinSignatureParts([ - ...baseParts, - row.interactionId, - row.approvalKind, - row.lifecycle, - row.approvalKind === "permission-grant" ? row.grantScope : null, - row.approvalKind === "permission-grant" ? row.statusReason : null, - row.target.itemId, - row.target.toolName, - ]); - case "question": - return joinSignatureParts([ - ...baseParts, - row.interactionId, - row.lifecycle, - row.statusReason, - row.questions - .map((question) => - joinSignatureParts([ - question.id, - question.prompt, - question.shortLabel, - question.multiSelect, - question.allowFreeText, - question.options - ?.map((option) => - joinSignatureParts([ - option.value, - option.label, - option.description, - ]), - ) - .join("\u001d"), - ]), - ) - .join("\u001e"), - row.answers - ? Object.entries(row.answers) - .map(([questionId, answer]) => - joinSignatureParts([ - questionId, - answer.selected.join("\u001d"), - answer.freeText, - ]), - ) - .join("\u001e") - : null, - ]); - default: - return assertNever(row); - } -} - -export function timelineRowRenderSignature(row: ThreadTimelineViewRow): string { - const cached = rowSignatureCache.get(row); - if (cached !== undefined) return cached; - const signature = computeTimelineRowRenderSignature(row); - rowSignatureCache.set(row, signature); - return signature; -} - -function computeTimelineRowRenderSignature(row: ThreadTimelineViewRow): string { - const baseSignature = timelineRowBaseSignature(row); - switch (row.kind) { - case "conversation": - return joinSignatureParts([ - baseSignature, - row.role, - row.turnRequest?.kind, - row.turnRequest?.status, - row.attachments?.localFiles, - row.attachments?.localImages, - row.attachments?.webImages, - ]); - case "system": - if (row.systemKind === "operation") { - return joinSignatureParts([ - baseSignature, - row.status, - row.systemKind, - row.operationKind, - row.operationKind === "parent-change" - ? row.parentChange.action - : null, - row.operationKind === "parent-change" - ? row.parentChange.previousParentThreadId - : null, - row.operationKind === "parent-change" - ? row.parentChange.previousParentThreadTitle - : null, - row.operationKind === "parent-change" - ? row.parentChange.nextParentThreadId - : null, - row.operationKind === "parent-change" - ? row.parentChange.nextParentThreadTitle - : null, - row.title, - row.detail, - ]); - } - return joinSignatureParts([ - baseSignature, - row.status, - row.systemKind, - row.title, - row.detail, - ]); - case "bundle-summary": - case "step-summary": - return joinSignatureParts([ - baseSignature, - row.status, - timelineRowsSignature(row.children), - ]); - case "turn": - return joinSignatureParts([ - baseSignature, - row.status, - row.summaryCount, - row.completedAt, - row.children ? timelineRowsSignature(row.children) : null, - ]); - case "work": - return timelineWorkRowRenderSignature(row); - default: - return assertNever(row); - } -} +// Moved to @bb/client-core (shared with the native app); re-exported here so web imports keep resolving. +export { + joinSignatureParts, + timelineRowsSignature, + timelineRowRenderSignature, +} from "@bb/client-core"; diff --git a/apps/app/src/components/thread/timeline/useThreadTimelineController.ts b/apps/app/src/components/thread/timeline/useThreadTimelineController.ts index 421cf8c421..281dc62b93 100644 --- a/apps/app/src/components/thread/timeline/useThreadTimelineController.ts +++ b/apps/app/src/components/thread/timeline/useThreadTimelineController.ts @@ -1,16 +1,38 @@ import { useCallback, useEffect, useMemo, useState } from "react"; -import type { - ThreadTimelineResponse, - TimelinePaginationCursor, - TimelineRow, -} from "@bb/server-contract"; +import type { ThreadTimelineResponse, TimelineRow } from "@bb/server-contract"; +import { + areTimelinePaginationCursorsEqual, + buildLoadedTimelineState, + buildSurfaceKey, + filterThreadTimelineResponse, + filterTimelineRows, + mergeLoadedTimelineWithLatest, + prependOlderTimelineRows, + recoverLoadedTimelineAfterStaleCursor, + type LoadedTimelineState, + type ThreadTimelineRowFilter, +} from "@bb/client-core"; import { useConnectionAwareQueryState } from "@/hooks/queries/connection-aware-query-state"; import { isTransientReadError } from "@/hooks/queries/query-helpers"; import { useThreadTimeline } from "@/hooks/queries/thread-queries"; -import { isOptimisticTimelineRowId } from "@/lib/optimistic-timeline-row"; import { BbHttpError, sdk } from "@/lib/sdk"; -export type ThreadTimelineRowFilter = (row: TimelineRow) => boolean; +// The pure merge helpers live in @bb/client-core so the native app can share +// them; re-exported here so existing web imports keep resolving. +export { + mergeLatestTimelineRows, + mergeLoadedTimelineWithLatest, + prependOlderTimelineRows, + recoverLoadedTimelineAfterStaleCursor, +} from "@bb/client-core"; +export type { + LoadedTimelineState, + MergeLatestTimelineRowsArgs, + MergeLoadedTimelineWithLatestArgs, + PrependOlderTimelineRowsArgs, + RecoverLoadedTimelineAfterStaleCursorArgs, + ThreadTimelineRowFilter, +} from "@bb/client-core"; export interface UseThreadTimelineControllerArgs { enabled?: boolean; @@ -36,438 +58,6 @@ export interface UseThreadTimelineControllerResult { timelineRows: TimelineRow[]; } -type NullableTimelinePaginationCursor = TimelinePaginationCursor | null; - -export interface LoadedTimelineState { - /** Inclusive end of the latest server window already merged into `rows`. */ - latestWindowEndSequence: number | null; - olderCursor: NullableTimelinePaginationCursor; - rows: TimelineRow[]; - surfaceKey: string; -} - -interface BuildLoadedTimelineStateArgs { - latestWindowEndSequence: number | null; - latestRows: TimelineRow[]; - olderCursor: NullableTimelinePaginationCursor; - surfaceKey: string; -} - -interface AreTimelinePaginationCursorsEqualArgs { - left: NullableTimelinePaginationCursor; - right: NullableTimelinePaginationCursor; -} - -export interface MergeLatestTimelineRowsArgs { - latestRows: readonly TimelineRow[]; - latestWindowStartSequence: number; - loadedRows: TimelineRow[]; -} - -interface MergeLatestTimelineRowsResult { - canMerge: boolean; - rows: TimelineRow[]; -} - -interface TimelineRowIdentityEntry { - row: TimelineRow; - signature: string; -} - -interface PreserveTimelineRowIdentityArgs { - nextRows: readonly TimelineRow[]; - previousRows: readonly TimelineRow[]; -} - -interface AreTimelineRowReferencesEqualArgs { - left: readonly TimelineRow[]; - right: readonly TimelineRow[]; -} - -export interface PrependOlderTimelineRowsArgs { - loadedRows: readonly TimelineRow[]; - olderRows: readonly TimelineRow[]; -} - -export interface MergeLoadedTimelineWithLatestArgs { - current: LoadedTimelineState; - latestTimeline: ThreadTimelineResponse; - surfaceKey: string; -} - -export interface RecoverLoadedTimelineAfterStaleCursorArgs { - current: LoadedTimelineState; - latestTimeline: ThreadTimelineResponse; - surfaceKey: string; -} - -interface BuildSurfaceKeyArgs { - rowFilter: ThreadTimelineRowFilter | undefined; - surfaceKey: string | undefined; - threadId: string; -} - -function buildSurfaceKey({ - rowFilter, - surfaceKey, - threadId, -}: BuildSurfaceKeyArgs): string { - if (surfaceKey !== undefined) { - return surfaceKey; - } - return rowFilter === undefined ? threadId : `${threadId}:filtered`; -} - -function filterTimelineRows({ - rowFilter, - rows, -}: { - rowFilter: ThreadTimelineRowFilter | undefined; - rows: readonly TimelineRow[]; -}): TimelineRow[] { - return rowFilter === undefined ? [...rows] : rows.filter(rowFilter); -} - -function filterThreadTimelineResponse({ - response, - rowFilter, -}: { - response: ThreadTimelineResponse; - rowFilter: ThreadTimelineRowFilter | undefined; -}): ThreadTimelineResponse { - if (rowFilter === undefined) { - return response; - } - return { - ...response, - rows: response.rows.filter(rowFilter), - }; -} - -function buildLoadedTimelineState({ - latestWindowEndSequence, - latestRows, - olderCursor, - surfaceKey, -}: BuildLoadedTimelineStateArgs): LoadedTimelineState { - return { - latestWindowEndSequence, - olderCursor, - rows: latestRows, - surfaceKey, - }; -} - -function areTimelinePaginationCursorsEqual({ - left, - right, -}: AreTimelinePaginationCursorsEqualArgs): boolean { - if (left === null || right === null) { - return left === right; - } - return left.anchorSeq === right.anchorSeq && left.anchorId === right.anchorId; -} - -function appendTimelineRowsPreservingOrder( - target: TimelineRow[], - rows: readonly TimelineRow[], -): void { - const seenIds = new Set(target.map((row) => row.id)); - for (const row of rows) { - if (seenIds.has(row.id)) { - continue; - } - seenIds.add(row.id); - target.push(row); - } -} - -function timelineRowIdentitySignature(row: TimelineRow): string { - return [ - row.kind, - row.id, - row.threadId, - row.turnId ?? "", - row.sourceSeqStart, - row.sourceSeqEnd, - row.startedAt, - row.createdAt, - ].join("\u001f"); -} - -function buildTimelineRowIdentityMap( - rows: readonly TimelineRow[], -): ReadonlyMap { - const rowsById = new Map(); - for (const row of rows) { - rowsById.set(row.id, { - row, - signature: timelineRowIdentitySignature(row), - }); - } - return rowsById; -} - -function preserveTimelineRowIdentity({ - nextRows, - previousRows, -}: PreserveTimelineRowIdentityArgs): TimelineRow[] { - const previousRowsById = buildTimelineRowIdentityMap(previousRows); - return nextRows.map((row) => { - const previous = previousRowsById.get(row.id); - if (previous && previous.signature === timelineRowIdentitySignature(row)) { - return previous.row; - } - return row; - }); -} - -function areTimelineRowReferencesEqual({ - left, - right, -}: AreTimelineRowReferencesEqualArgs): boolean { - if (left.length !== right.length) return false; - return left.every((row, index) => row === right[index]); -} - -export function prependOlderTimelineRows({ - loadedRows, - olderRows, -}: PrependOlderTimelineRowsArgs): TimelineRow[] { - const rows: TimelineRow[] = []; - appendTimelineRowsPreservingOrder(rows, olderRows); - appendTimelineRowsPreservingOrder(rows, loadedRows); - return rows; -} - -export function mergeLatestTimelineRows({ - latestRows, - latestWindowStartSequence, - loadedRows: retainedRows, -}: MergeLatestTimelineRowsArgs): MergeLatestTimelineRowsResult { - // Optimistic rows are carried by `latestRows` (they are written into the - // timeline cache) and disappear from it once the server's real row lands. - // Retaining a copy here would survive that swap: an id minted client-side - // never overlaps a server id, so the no-overlap branch below would append - // the server row *after* the stale optimistic one and the message would - // render twice. This is only observable when nothing else overlaps — a - // thread whose first message is being sent, e.g. a fresh side chat. - const loadedRows = retainedRows.some((row) => - isOptimisticTimelineRowId(row.id), - ) - ? retainedRows.filter((row) => !isOptimisticTimelineRowId(row.id)) - : retainedRows; - - const identityPreservedLatestRows = preserveTimelineRowIdentity({ - nextRows: latestRows, - previousRows: loadedRows, - }); - - if (loadedRows.length === 0) { - return { - canMerge: true, - rows: identityPreservedLatestRows, - }; - } - - const latestRowsById = new Map( - identityPreservedLatestRows.map((row) => [row.id, row]), - ); - // The latest response is authoritative only from its raw sequence boundary - // onward. Keep every older row regardless of its kind. A row crossing the - // boundary is kept only when the new projection carries the same identity, - // in which case its value is replaced in place below. - const rowsToRetain = loadedRows.filter( - (row) => - row.sourceSeqEnd < latestWindowStartSequence || - latestRowsById.has(row.id), - ); - const retainedRowIds = new Set(rowsToRetain.map((row) => row.id)); - const loadedCommonIds = rowsToRetain.flatMap((row) => - latestRowsById.has(row.id) ? [row.id] : [], - ); - const latestCommonIds = identityPreservedLatestRows.flatMap((row) => - retainedRowIds.has(row.id) ? [row.id] : [], - ); - if ( - loadedCommonIds.length !== latestCommonIds.length || - loadedCommonIds.some((id, index) => id !== latestCommonIds[index]) - ) { - // The two projections disagree about row order. There is no unambiguous - // splice, so the caller must rebuild from the authoritative latest page. - return { canMerge: false, rows: identityPreservedLatestRows }; - } - - // New latest rows belong immediately before their next shared row. This - // preserves the old position of a straddling row (and therefore older rows - // around it), while still honoring server order for newly projected rows. - const rowsBeforeSharedId = new Map(); - let pendingRows: TimelineRow[] = []; - for (const row of identityPreservedLatestRows) { - if (!retainedRowIds.has(row.id)) { - pendingRows.push(row); - continue; - } - if (pendingRows.length > 0) { - rowsBeforeSharedId.set(row.id, pendingRows); - pendingRows = []; - } - } - - const rows: TimelineRow[] = []; - for (const row of rowsToRetain) { - const rowsBefore = rowsBeforeSharedId.get(row.id); - if (rowsBefore) { - rows.push(...rowsBefore); - } - rows.push(latestRowsById.get(row.id) ?? row); - } - rows.push(...pendingRows); - if (areTimelineRowReferencesEqual({ left: loadedRows, right: rows })) { - return { - canMerge: true, - rows: loadedRows, - }; - } - - return { - canMerge: true, - rows, - }; -} - -/** - * First event sequence a window covers. Every pagination cursor names the first - * sequence the page that issued it covered — that is what makes older pages - * chain — so the cursor is the exact lower bound of the window it arrived with. - * No cursor means the page reached the start of the thread. - */ -function timelineWindowStartSequence(timeline: ThreadTimelineResponse): number { - return timeline.timelinePage.olderCursor?.anchorSeq ?? 0; -} - -/** - * Whether the fresh window continues the loaded one, in raw event sequences. - * - * Rows cannot answer this, in three separate ways: - * - * - Most events never become a row — `turn/completed`, token-usage and - * rate-limit updates — so the distance from the last loaded row to the next - * window is routinely non-zero while the history is in fact continuous. A - * follow-up submitted on a budgeted thread lands exactly here: the prompt - * opens the next window one sequence past a `turn/completed` that is not a - * row. - * - Rows are ordered by where they start, not where they end, so the last row - * is not the one that reaches furthest. A turn summary spans its whole turn - * while shorter rows that begin later sort after it. - * - A window's first row can start *below* the window, because the projection - * backfills a turn's `turn/started` row from under the cut. - * - * Each shape reports a break that is not there, and the caller answers a break - * by dropping every loaded page — the timeline visibly truncates to the newest - * window and refills as auto-load pages it back. The sequences the server - * states outright have none of these failure modes. - */ -function timelineWindowsAreContiguous( - current: LoadedTimelineState, - latestTimeline: ThreadTimelineResponse, -): boolean { - return ( - current.latestWindowEndSequence !== null && - latestTimeline.maxSeq >= current.latestWindowEndSequence && - timelineWindowStartSequence(latestTimeline) <= - current.latestWindowEndSequence + 1 - ); -} - -function mergeLoadedTimelineOlderCursor( - current: NullableTimelinePaginationCursor, - latest: NullableTimelinePaginationCursor, -): NullableTimelinePaginationCursor { - if (current === null || latest === null) { - return null; - } - return latest.anchorSeq <= current.anchorSeq ? latest : current; -} - -export function mergeLoadedTimelineWithLatest({ - current, - latestTimeline, - surfaceKey, -}: MergeLoadedTimelineWithLatestArgs): LoadedTimelineState { - if ( - current.surfaceKey !== surfaceKey || - !timelineWindowsAreContiguous(current, latestTimeline) - ) { - return buildLoadedTimelineState({ - latestWindowEndSequence: latestTimeline.maxSeq, - latestRows: latestTimeline.rows, - olderCursor: latestTimeline.timelinePage.olderCursor, - surfaceKey, - }); - } - - const latestMerge = mergeLatestTimelineRows({ - latestRows: latestTimeline.rows, - latestWindowStartSequence: timelineWindowStartSequence(latestTimeline), - loadedRows: current.rows, - }); - if (!latestMerge.canMerge) { - return buildLoadedTimelineState({ - latestWindowEndSequence: latestTimeline.maxSeq, - latestRows: latestTimeline.rows, - olderCursor: latestTimeline.timelinePage.olderCursor, - surfaceKey, - }); - } - - return { - ...current, - latestWindowEndSequence: latestTimeline.maxSeq, - olderCursor: mergeLoadedTimelineOlderCursor( - current.olderCursor, - latestTimeline.timelinePage.olderCursor, - ), - rows: latestMerge.rows, - }; -} - -export function recoverLoadedTimelineAfterStaleCursor({ - current, - latestTimeline, - surfaceKey, -}: RecoverLoadedTimelineAfterStaleCursorArgs): LoadedTimelineState { - if (current.surfaceKey !== surfaceKey) { - return buildLoadedTimelineState({ - latestWindowEndSequence: latestTimeline.maxSeq, - latestRows: latestTimeline.rows, - olderCursor: latestTimeline.timelinePage.olderCursor, - surfaceKey, - }); - } - - const latestMerge = mergeLatestTimelineRows({ - latestRows: latestTimeline.rows, - latestWindowStartSequence: timelineWindowStartSequence(latestTimeline), - loadedRows: current.rows, - }); - if (!latestMerge.canMerge) { - return buildLoadedTimelineState({ - latestWindowEndSequence: latestTimeline.maxSeq, - latestRows: latestTimeline.rows, - olderCursor: latestTimeline.timelinePage.olderCursor, - surfaceKey, - }); - } - - return { - latestWindowEndSequence: latestTimeline.maxSeq, - olderCursor: latestTimeline.timelinePage.olderCursor, - rows: latestMerge.rows, - surfaceKey, - }; -} - export function isStaleTimelinePaginationCursorError(error: Error): boolean { return ( error instanceof BbHttpError && diff --git a/apps/app/src/lib/api-types.ts b/apps/app/src/lib/api-types.ts index 1242bd3d7e..1e3fcb2cf1 100644 --- a/apps/app/src/lib/api-types.ts +++ b/apps/app/src/lib/api-types.ts @@ -1,30 +1,6 @@ -import type { ThreadOriginKind } from "@bb/domain"; -import type { CreateThreadRequest } from "@bb/server-contract"; - -export type AppCreateThreadRequest = Omit< - CreateThreadRequest, - "origin" | "startedOnBehalfOf" | "originKind" -> & - Partial>; - -export interface ThreadListFilters { - projectId?: string; - parentThreadId?: string; - sourceThreadId?: string; - /** Restrict to threads filed directly under this section. */ - sectionId?: string; - /** Restrict to loose threads — those not filed under any section. */ - unsectioned?: boolean; - hasParent?: boolean; - /** Restrict to threads spawned with this origin. */ - originKind?: ThreadOriginKind; - /** App callers must choose active or archived; server omission intentionally means both. */ - archived: boolean; - limit?: number; - offset?: number; -} - -export interface ThreadSearchFilters { - query: string; - limitPerGroup?: number; -} +// Moved to @bb/client-core (shared with the native app); re-exported here so web imports keep resolving. +export type { + AppCreateThreadRequest, + ThreadListFilters, + ThreadSearchFilters, +} from "@bb/client-core"; diff --git a/apps/app/src/lib/automation-prompt.ts b/apps/app/src/lib/automation-prompt.ts index 3c7f19a173..7d4e262431 100644 --- a/apps/app/src/lib/automation-prompt.ts +++ b/apps/app/src/lib/automation-prompt.ts @@ -1,17 +1,5 @@ -import type { PromptMentionResource } from "@bb/domain"; -import { CREATE_AUTOMATION_PROMPT } from "@/lib/create-resource-prompts"; - -export const SUBMITTED_AUTOMATION_PROMPT_PREFIX = - CREATE_AUTOMATION_PROMPT.trimEnd(); - -export function isAutomationPromptCommandResource( - resource: PromptMentionResource, -): boolean { - return ( - resource.kind === "command" && - resource.trigger === "/" && - (resource.name === "automation" || resource.name === "loop") && - resource.source === "command" && - resource.origin === "user" - ); -} +// Moved to @bb/client-core (shared with the native app); re-exported here so web imports keep resolving. +export { + SUBMITTED_AUTOMATION_PROMPT_PREFIX, + isAutomationPromptCommandResource, +} from "@bb/client-core"; diff --git a/apps/app/src/lib/codepoint-compare.ts b/apps/app/src/lib/codepoint-compare.ts index 7c7be9d5dc..9abb1467b6 100644 --- a/apps/app/src/lib/codepoint-compare.ts +++ b/apps/app/src/lib/codepoint-compare.ts @@ -1,17 +1,2 @@ -/** - * Compare two strings by Unicode codepoint, matching the server's SQLite binary - * `asc()` collation and the fractional-index key generator - * (`createOrderKeyBetween`, which compares with `<`/`>=`). Use this — not - * `String.localeCompare` — whenever client ordering of an order key (`sortKey`, - * `pinSortKey`) or an `id` must agree with the server, since `localeCompare` - * folds case and reorders letters vs. digits. - */ -export function compareCodepoint(left: string, right: string): number { - if (left < right) { - return -1; - } - if (left > right) { - return 1; - } - return 0; -} +// Moved to @bb/client-core (shared with the native app); re-exported here so web imports keep resolving. +export { compareCodepoint } from "@bb/client-core"; diff --git a/apps/app/src/lib/create-resource-prompts.ts b/apps/app/src/lib/create-resource-prompts.ts index 3142ba67a8..053b0b691e 100644 --- a/apps/app/src/lib/create-resource-prompts.ts +++ b/apps/app/src/lib/create-resource-prompts.ts @@ -1,10 +1,6 @@ -/** - * The prompt prefixes that seed the composer when the user asks bb to create - * one of its own resources. Every entry point for a kind — library button, - * settings button, composer menu — uses the same prefix, so the instruction the - * agent reads does not drift between surfaces. - */ - -export const CREATE_SKILL_PROMPT = "Create a new bb skill that "; -export const CREATE_AUTOMATION_PROMPT = "Create a new bb automation to "; -export const CREATE_PLUGIN_PROMPT = "Create a new bb plugin that "; +// Moved to @bb/client-core (shared with the native app); re-exported here so web imports keep resolving. +export { + CREATE_SKILL_PROMPT, + CREATE_AUTOMATION_PROMPT, + CREATE_PLUGIN_PROMPT, +} from "@bb/client-core"; diff --git a/apps/app/src/lib/file-preview.ts b/apps/app/src/lib/file-preview.ts index fd26fdd6f1..02f373b8cd 100644 --- a/apps/app/src/lib/file-preview.ts +++ b/apps/app/src/lib/file-preview.ts @@ -1,286 +1,30 @@ -const DEFAULT_FILE_PREVIEW_MIME_TYPE = "application/octet-stream"; -const textDecoder = new TextDecoder(); -const strictUtf8TextDecoder = new TextDecoder("utf-8", { fatal: true }); - -const UTF8_TEXT_MIME_TYPES = new Set([ - "application/ecmascript", - "application/javascript", - "application/json", - "application/ld+json", - "application/sql", - "application/toml", - "application/typescript", - "application/x-httpd-php", - "application/x-sh", - "application/x-typescript", - "application/xml", - "application/x-yaml", - "application/yaml", -]); - -const MARKDOWN_FILE_EXTENSIONS = [".md", ".markdown"]; -const MARKDOWN_MIME_TYPES = new Set(["text/markdown", "text/x-markdown"]); -const CSV_FILE_EXTENSIONS = [".csv"]; -const CSV_MIME_TYPES = new Set(["application/csv", "text/csv"]); -const HTML_FILE_EXTENSION = ".html"; -const NULL_CHARACTER = "\u0000"; - -export interface FilePreviewTarget { - name?: string; - path: string; - url: string; -} - -interface FilePreviewBase extends FilePreviewTarget { - kind: "image" | "text" | "unsupported" | "video"; - mimeType: string; -} - -export interface ImageFilePreview extends FilePreviewBase { - kind: "image"; -} - -export interface VideoFilePreview extends FilePreviewBase { - kind: "video"; -} - -export interface TextFilePreview extends FilePreviewBase { - kind: "text"; - content: string; -} - -export interface UnsupportedFilePreview extends FilePreviewBase { - kind: "unsupported"; -} - -export type FilePreview = - | ImageFilePreview - | VideoFilePreview - | TextFilePreview - | UnsupportedFilePreview; - -export type EnvironmentFilePreviewSource = - | { kind: "working-tree" } - | { kind: "head" } - | { kind: "merge-base"; ref: string }; - -export type WorkspaceFilePreviewStatusLabel = "deleted"; - -export interface FilePreviewLineRange { - endLineNumber: number; - startLineNumber: number; -} - -export interface CreateFilePreviewLineRangeArgs { - endLineNumber: number; - startLineNumber: number; -} - -export interface AreFilePreviewLineRangesEqualArgs { - a: FilePreviewLineRange | null; - b: FilePreviewLineRange | null; -} - -export interface GetFilePreviewLineRangeStartArgs { - lineRange: FilePreviewLineRange | null; -} - -export interface WorkspaceFileTabState { - lineRange: FilePreviewLineRange | null; - path: string; - source: EnvironmentFilePreviewSource; - statusLabel: WorkspaceFilePreviewStatusLabel | null; -} - -export interface HostFileTabState { - lineRange: FilePreviewLineRange | null; - path: string; -} - -export interface ThreadStorageFileTabState { - lineRange: FilePreviewLineRange | null; - path: string; -} - -export function createFilePreviewLineRange({ - endLineNumber, - startLineNumber, -}: CreateFilePreviewLineRangeArgs): FilePreviewLineRange | null { - if ( - !Number.isSafeInteger(startLineNumber) || - !Number.isSafeInteger(endLineNumber) || - startLineNumber <= 0 || - endLineNumber <= 0 || - startLineNumber > endLineNumber - ) { - return null; - } - - return { - endLineNumber, - startLineNumber, - }; -} - -export function areFilePreviewLineRangesEqual({ - a, - b, -}: AreFilePreviewLineRangesEqualArgs): boolean { - if (a === null || b === null) { - return a === b; - } - return ( - a.startLineNumber === b.startLineNumber && - a.endLineNumber === b.endLineNumber - ); -} - -export function getFilePreviewLineRangeStart({ - lineRange, -}: GetFilePreviewLineRangeStartArgs): number | null { - return lineRange?.startLineNumber ?? null; -} - -export function areEnvironmentFilePreviewSourcesEqual( - a: EnvironmentFilePreviewSource, - b: EnvironmentFilePreviewSource, -): boolean { - if (a.kind !== b.kind) { - return false; - } - - switch (a.kind) { - case "working-tree": - case "head": - return true; - case "merge-base": - return b.kind === "merge-base" && a.ref === b.ref; - default: { - const exhaustive: never = a; - return exhaustive; - } - } -} - -export interface BuildFilePreviewArgs extends FilePreviewTarget { - contentBytes: Uint8Array; - mimeType: string; -} - -function isKnownTextMimeType(mimeType: string): boolean { - return ( - mimeType.startsWith("text/") || - mimeType.endsWith("+json") || - mimeType.endsWith("+xml") || - UTF8_TEXT_MIME_TYPES.has(mimeType) - ); -} - -function decodeUtf8Text(contentBytes: Uint8Array): string | null { - try { - const content = strictUtf8TextDecoder.decode(contentBytes); - return content.includes(NULL_CHARACTER) ? null : content; - } catch { - return null; - } -} - -function decodeDeclaredTextContent(contentBytes: Uint8Array): string | null { - const content = textDecoder.decode(contentBytes); - return content.includes(NULL_CHARACTER) ? null : content; -} - -function hasMarkdownExtension(path: string): boolean { - const normalizedPath = path.toLowerCase(); - return MARKDOWN_FILE_EXTENSIONS.some((extension) => - normalizedPath.endsWith(extension), - ); -} - -function hasCsvExtension(path: string): boolean { - const normalizedPath = path.toLowerCase(); - return CSV_FILE_EXTENSIONS.some((extension) => - normalizedPath.endsWith(extension), - ); -} - -export function isHtmlFilePreviewPath(path: string): boolean { - return path.toLowerCase().endsWith(HTML_FILE_EXTENSION); -} - -export function normalizeFilePreviewMimeType(value: string | null): string { - const normalizedValue = value?.split(";")[0]?.trim().toLowerCase(); - return normalizedValue && normalizedValue.length > 0 - ? normalizedValue - : DEFAULT_FILE_PREVIEW_MIME_TYPE; -} - -export function isMarkdownFilePreview(preview: FilePreview): boolean { - return ( - preview.kind === "text" && - (MARKDOWN_MIME_TYPES.has(preview.mimeType) || - hasMarkdownExtension(preview.path) || - (preview.name ? hasMarkdownExtension(preview.name) : false)) - ); -} - -export function isCsvFilePreview(preview: FilePreview): boolean { - return ( - preview.kind === "text" && - (CSV_MIME_TYPES.has(preview.mimeType) || - hasCsvExtension(preview.path) || - (preview.name ? hasCsvExtension(preview.name) : false)) - ); -} - -export function buildFilePreview(args: BuildFilePreviewArgs): FilePreview { - const base = { - mimeType: args.mimeType, - name: args.name, - path: args.path, - url: args.url, - }; - - if (args.mimeType.startsWith("image/")) { - return { - kind: "image", - ...base, - }; - } - - if (isKnownTextMimeType(args.mimeType)) { - const textContent = decodeDeclaredTextContent(args.contentBytes); - if (textContent === null) { - return { - kind: "unsupported", - ...base, - }; - } - return { - kind: "text", - ...base, - content: textContent, - }; - } - - const fallbackTextContent = decodeUtf8Text(args.contentBytes); - if (fallbackTextContent !== null) { - return { - kind: "text", - ...base, - content: fallbackTextContent, - }; - } - - if (args.mimeType.startsWith("video/")) { - return { - kind: "video", - ...base, - }; - } - - return { - kind: "unsupported", - ...base, - }; -} +// Moved to @bb/client-core (shared with the native app); re-exported here so web imports keep resolving. +export { + createFilePreviewLineRange, + areFilePreviewLineRangesEqual, + getFilePreviewLineRangeStart, + areEnvironmentFilePreviewSourcesEqual, + isHtmlFilePreviewPath, + normalizeFilePreviewMimeType, + isMarkdownFilePreview, + isCsvFilePreview, + buildFilePreview, +} from "@bb/client-core"; +export type { + FilePreviewTarget, + ImageFilePreview, + VideoFilePreview, + TextFilePreview, + UnsupportedFilePreview, + FilePreview, + EnvironmentFilePreviewSource, + WorkspaceFilePreviewStatusLabel, + FilePreviewLineRange, + CreateFilePreviewLineRangeArgs, + AreFilePreviewLineRangesEqualArgs, + GetFilePreviewLineRangeStartArgs, + WorkspaceFileTabState, + HostFileTabState, + ThreadStorageFileTabState, + BuildFilePreviewArgs, +} from "@bb/client-core"; diff --git a/apps/app/src/lib/fixed-panel-tabs-state.ts b/apps/app/src/lib/fixed-panel-tabs-state.ts index 2ec429cfe1..f862b74785 100644 --- a/apps/app/src/lib/fixed-panel-tabs-state.ts +++ b/apps/app/src/lib/fixed-panel-tabs-state.ts @@ -1,463 +1,73 @@ -import { z } from "zod"; +// The persisted-panel schema, tab constructors, and normalization live in +// @bb/client-core (shared with the native app). This module re-exports them and +// keeps the two web-only pieces: browser tabs (nanoid ids for the desktop +// browser) and localStorage pruning. import { nanoid } from "nanoid"; import { - BB_DESKTOP_BROWSER_MAX_TITLE_LENGTH, - BB_DESKTOP_BROWSER_MAX_URL_LENGTH, -} from "@bb/desktop-contract"; -import { - terminalCreateTargetSchema, - threadTabFileOpenerOwnerSchema, - type TerminalCreateTarget, - type ThreadTabFileOpenerOwner, -} from "@bb/server-contract"; -import { - areFilePreviewLineRangesEqual, - areEnvironmentFilePreviewSourcesEqual, - type EnvironmentFilePreviewSource, - type FilePreviewLineRange, - type HostFileTabState, - type ThreadStorageFileTabState, - type WorkspaceFilePreviewStatusLabel, - type WorkspaceFileTabState, -} from "./file-preview"; - -export const FIXED_PANEL_TABS_STATE_STORAGE_PREFIX = - "bb.thread.fixedPanelTabsState"; -export const FIXED_PANEL_TABS_STATE_STORAGE_VERSION = 1; -export const FIXED_PANEL_TABS_IDLE_EXPIRY_MS = 14 * 24 * 60 * 60 * 1000; - -const SECONDARY_PANEL_TAB_ID_ENVIRONMENT_NONE = "none"; -const THREAD_INFO_TAB_ID = "thread-info:thread-info:none"; -const GIT_DIFF_TAB_ID = "git-diff:git-diff:none"; -const NEW_TAB_TAB_ID = "new-tab:new-tab:none"; - -const environmentFilePreviewSourceSchema: z.ZodType = - z.discriminatedUnion("kind", [ - z - .object({ - kind: z.literal("working-tree"), - }) - .strict(), - z - .object({ - kind: z.literal("head"), - }) - .strict(), - z - .object({ - kind: z.literal("merge-base"), - ref: z.string().min(1), - }) - .strict(), - ]); -const workspaceFilePreviewStatusLabelSchema: z.ZodType = - z.literal("deleted").nullable(); -const filePreviewLineRangeSchema: z.ZodType = z - .object({ - endLineNumber: z.number().int().positive(), - startLineNumber: z.number().int().positive(), - }) - .strict() - .refine((range) => range.startLineNumber <= range.endLineNumber); -const threadInfoFixedPanelTabSchema = z - .object({ - id: z.string().min(1), - kind: z.literal("thread-info"), - }) - .strict(); -const gitDiffFixedPanelTabSchema = z - .object({ - id: z.string().min(1), - kind: z.literal("git-diff"), - }) - .strict(); -const pluginPageFixedPanelTabSchema = z - .object({ - fixedTabId: z.string().min(1), - id: z.string().min(1), - kind: z.literal("plugin-page-fixed"), - pageId: z.string().min(1), - pluginId: z.string().min(1), - }) - .strict(); -const workspaceFilePreviewFixedPanelTabSchema = z - .object({ - environmentId: z.string().min(1).nullable(), - id: z.string().min(1), - kind: z.literal("workspace-file-preview"), - lineRange: filePreviewLineRangeSchema.nullable().default(null), - path: z.string().min(1), - projectId: z.string().min(1).nullable().default(null), - source: environmentFilePreviewSourceSchema, - statusLabel: workspaceFilePreviewStatusLabelSchema, - }) - .strict(); -const hostFilePreviewFixedPanelTabSchema = z - .object({ - environmentId: z.string().min(1).nullable().default(null), - id: z.string().min(1), - kind: z.literal("host-file-preview"), - lineRange: filePreviewLineRangeSchema.nullable().default(null), - path: z.string().min(1), - threadId: z.string().min(1).nullable().default(null), - }) - .strict(); -const threadStorageFilePreviewFixedPanelTabSchema = z - .object({ - environmentId: z.string().min(1).nullable().default(null), - id: z.string().min(1), - isPinned: z.boolean(), - kind: z.literal("thread-storage-file-preview"), - lineRange: filePreviewLineRangeSchema.nullable().default(null), - path: z.string().min(1), - threadId: z.string().min(1).nullable().default(null), - }) - .strict(); -const browserFixedPanelTabSchema = z - .object({ - environmentId: z.string().min(1).nullable().default(null), - id: z.string().min(1), - kind: z.literal("browser"), - title: z - .string() - .min(1) - .max(BB_DESKTOP_BROWSER_MAX_TITLE_LENGTH) - .nullable(), - url: z.string().max(BB_DESKTOP_BROWSER_MAX_URL_LENGTH), - }) - .strict(); -const newTabFixedPanelTabSchema = z - .object({ - id: z.string().min(1), - kind: z.literal("new-tab"), - }) - .strict(); -const terminalFixedPanelTabSchema = z - .object({ - id: z.string().min(1), - kind: z.literal("terminal"), - terminalId: z.string().min(1), - // Nav-panel right panels can host terminals from an explicit target. The - // field is absent on thread/root-compose tabs, whose surface owns it. - target: terminalCreateTargetSchema.optional(), - }) - .strict(); -const pluginPanelFixedPanelTabSchema = z - .object({ - actionId: z.string().min(1), - fileOpenerOwner: threadTabFileOpenerOwnerSchema.optional(), - id: z.string().min(1), - kind: z.literal("plugin-panel"), - paramsJson: z.string().nullable(), - pluginId: z.string().min(1), - title: z.string().min(1), - }) - .strict(); -const secondaryFixedPanelTabSchema = z.union([ - threadInfoFixedPanelTabSchema, - gitDiffFixedPanelTabSchema, - pluginPageFixedPanelTabSchema, - pluginPanelFixedPanelTabSchema, - workspaceFilePreviewFixedPanelTabSchema, - hostFilePreviewFixedPanelTabSchema, - threadStorageFilePreviewFixedPanelTabSchema, - browserFixedPanelTabSchema, - newTabFixedPanelTabSchema, - terminalFixedPanelTabSchema, -]); -/** - * The native side chat is gone, but persisted panel state can still hold its - * tabs. Drop them at the parse boundary so old state loads and the removed - * tabs simply disappear from the strip. - */ -const secondaryFixedPanelTabsSchema = z.preprocess( - (value) => - Array.isArray(value) - ? value.filter( - (tab) => - !( - typeof tab === "object" && - tab !== null && - (tab as { kind?: unknown }).kind === "side-chat" - ), - ) - : value, - z.array(secondaryFixedPanelTabSchema), -); -const secondaryFixedPanelTabGroupStateSchema = z - .object({ - tabs: secondaryFixedPanelTabsSchema, - activeTabId: z.string().min(1).nullable(), - isOpen: z.boolean(), - }) - .strict(); -const fixedPanelTabsStateSchema = z - .object({ - version: z.literal(FIXED_PANEL_TABS_STATE_STORAGE_VERSION), - secondary: secondaryFixedPanelTabGroupStateSchema, - lastUsedAt: z.number().int().nonnegative(), - }) - .passthrough(); - -export interface ThreadInfoFixedPanelTab { - id: string; - kind: "thread-info"; -} - -export interface GitDiffFixedPanelTab { - id: string; - kind: "git-diff"; -} - -export interface PluginPageFixedPanelTab { - fixedTabId: string; - id: string; - kind: "plugin-page-fixed"; - pageId: string; - pluginId: string; -} - -export type FixedPanelViewTab = - | ThreadInfoFixedPanelTab - | GitDiffFixedPanelTab - | PluginPageFixedPanelTab; - -/** - * A panel tab opened by a plugin `threadPanelAction` (plugin design §5.2) — - * a closable file-strip tab like a terminal, not a fixed view. - * `paramsJson` is the JSON-serialized `openPanel` params (null = none); it - * is part of the tab identity, so the same action can hold several tabs - * with different params while identical re-opens focus the existing one. - * If the plugin/action is gone on restore the content degrades to a - * placeholder. - */ -export interface PluginPanelFixedPanelTab { - actionId: string; - /** Present only when this plugin panel diverted a native file preview. */ - fileOpenerOwner?: ThreadTabFileOpenerOwner; - id: string; - kind: "plugin-panel"; - paramsJson: string | null; - pluginId: string; - title: string; -} + buildFixedPanelTabId, + isFixedPanelTabsStateStorageKey, + shouldPruneStoredFixedPanelTabsState, + type BrowserFixedPanelTab, +} from "@bb/client-core"; + +export { + FIXED_PANEL_TABS_STATE_STORAGE_PREFIX, + FIXED_PANEL_TABS_STATE_STORAGE_VERSION, + FIXED_PANEL_TABS_IDLE_EXPIRY_MS, + buildFixedPanelTabId, + createThreadInfoFixedPanelTab, + createGitDiffFixedPanelTab, + createPluginPageFixedPanelTab, + createPluginPanelFixedPanelTab, + createWorkspaceFilePreviewFixedPanelTab, + createHostFilePreviewFixedPanelTab, + createThreadStorageFilePreviewFixedPanelTab, + createNewTabFixedPanelTab, + ensureOpenFixedPanelHasActiveTab, + createTerminalFixedPanelTab, + normalizeFixedPanelTabsState, + createEmptyFixedPanelTabsState, + EMPTY_FIXED_PANEL_TABS_STATE, + getFixedPanelTabsStateStorageKey, + isFixedPanelTabsStateStorageKey, + isFixedPanelTabsStateExpired, + parseFixedPanelTabsState, + parseFixedPanelTabsStateForStorage, + serializeFixedPanelTabsState, + shouldPruneStoredFixedPanelTabsState, + areFixedPanelTabsEquivalent, +} from "@bb/client-core"; +export type { + ThreadInfoFixedPanelTab, + GitDiffFixedPanelTab, + PluginPageFixedPanelTab, + FixedPanelViewTab, + PluginPanelFixedPanelTab, + WorkspaceFilePreviewFixedPanelTab, + HostFilePreviewFixedPanelTab, + ThreadStorageFilePreviewFixedPanelTab, + BrowserFixedPanelTab, + NewTabFixedPanelTab, + TerminalFixedPanelTab, + SecondaryFixedPanelTab, + SecondaryFileFixedPanelTab, + FixedPanelTab, + FixedPanelTabGroupState, + FixedSecondaryPanelTabGroupState, + FixedPanelTabsState, + ParseFixedPanelTabsStateArgs, + ParseFixedPanelTabsStateForStorageResult, +} from "@bb/client-core"; -export interface WorkspaceFilePreviewFixedPanelTab { - environmentId: string | null; - id: string; - kind: "workspace-file-preview"; - lineRange: FilePreviewLineRange | null; - path: string; - projectId: string | null; - source: EnvironmentFilePreviewSource; - statusLabel: WorkspaceFilePreviewStatusLabel | null; -} - -export interface HostFilePreviewFixedPanelTab { - environmentId: string | null; - id: string; - kind: "host-file-preview"; - lineRange: FilePreviewLineRange | null; - path: string; - threadId: string | null; -} - -export interface ThreadStorageFilePreviewFixedPanelTab { - environmentId: string | null; - id: string; - isPinned: boolean; - kind: "thread-storage-file-preview"; - lineRange: FilePreviewLineRange | null; - path: string; - threadId: string | null; -} - -/** - * A web browser tab hosted by a native Electron `WebContentsView` (desktop - * only). `url` is the last-loaded page (empty string = the new-tab screen) and - * `title` is the last title pushed from the view, so the tab pill keeps its - * label while inactive and across reloads. Favicons are intentionally not - * persisted/rendered (untrusted remote URL); the pill shows a generic globe. - * Live loading state is not persisted — it is held by the active tab's chrome. - */ -export interface BrowserFixedPanelTab { +interface CreateBrowserFixedPanelTabArgs { environmentId: string | null; - id: string; - kind: "browser"; - title: string | null; url: string; } -export interface NewTabFixedPanelTab { - id: string; - kind: "new-tab"; -} - -export interface TerminalFixedPanelTab { - id: string; - kind: "terminal"; - terminalId: string; - target?: TerminalCreateTarget; -} - -export type SecondaryFixedPanelTab = - | ThreadInfoFixedPanelTab - | GitDiffFixedPanelTab - | PluginPageFixedPanelTab - | PluginPanelFixedPanelTab - | WorkspaceFilePreviewFixedPanelTab - | HostFilePreviewFixedPanelTab - | ThreadStorageFilePreviewFixedPanelTab - | BrowserFixedPanelTab - | NewTabFixedPanelTab - | TerminalFixedPanelTab; - -/** - * The subset of secondary-panel tabs rendered as closable file tabs in the tab - * strip. Excludes thread-info and git-diff, which are fixed views toggled - * separately rather than ordered alongside opened files. - */ -export type SecondaryFileFixedPanelTab = - | WorkspaceFilePreviewFixedPanelTab - | HostFilePreviewFixedPanelTab - | ThreadStorageFilePreviewFixedPanelTab - | BrowserFixedPanelTab - | NewTabFixedPanelTab - | TerminalFixedPanelTab - | PluginPanelFixedPanelTab; - -export type FixedPanelTab = SecondaryFixedPanelTab; - -export interface FixedPanelTabGroupState { - tabs: readonly FixedPanelTab[]; - activeTabId: string | null; -} - -export interface FixedSecondaryPanelTabGroupState extends FixedPanelTabGroupState { - isOpen: boolean; -} - -export interface FixedPanelTabsState { - version: typeof FIXED_PANEL_TABS_STATE_STORAGE_VERSION; - secondary: FixedSecondaryPanelTabGroupState; - lastUsedAt: number; -} - -interface FixedPanelTabsStorageKeyArgs { - threadId: string; -} - -interface CreateFixedPanelTabsStateArgs { - lastUsedAt?: number; - secondary?: FixedSecondaryPanelTabGroupState; -} - -interface ParseFixedPanelTabsStateArgs { - initialValue: FixedPanelTabsState; - now: number; - storedValue: string | null; -} - -interface ParseFixedPanelTabsStateForStorageResult { - shouldPrune: boolean; - state: FixedPanelTabsState; -} - -interface SerializeFixedPanelTabsStateArgs { - state: FixedPanelTabsState; -} - -interface IsFixedPanelTabsStateExpiredArgs { - now: number; - state: FixedPanelTabsState; -} - interface PruneFixedPanelTabsStorageArgs { now: number; } -interface NormalizeFixedPanelTabsStateArgs { - state: FixedPanelTabsState; -} - -interface StripTransientFixedPanelTabsStateForStorageArgs { - state: FixedPanelTabsState; -} - -interface NormalizeFixedPanelTabGroupStateArgs { - group: FixedPanelTabGroupState; -} - -interface CreateThreadStorageFilePreviewFixedPanelTabArgs { - environmentId: string | null; - isPinned: boolean; - tab: ThreadStorageFileTabState; - threadId: string; -} - -interface CreateHostFilePreviewFixedPanelTabArgs { - environmentId: string; - tab: HostFileTabState; - threadId: string; -} - -interface CreateBrowserFixedPanelTabArgs { - environmentId: string | null; - url: string; -} - -interface CreateWorkspaceFilePreviewFixedPanelTabArgs { - environmentId: string | null; - projectId: string | null; - tab: WorkspaceFileTabState; -} - -interface CreateTerminalFixedPanelTabArgs { - terminalId: string; - target?: TerminalCreateTarget; -} - -interface CreatePluginPanelFixedPanelTabArgs { - actionId: string; - paramsJson: string | null; - pluginId: string; - title: string; -} - -interface CreatePluginPageFixedPanelTabArgs { - fixedTabId: string; - pageId: string; - pluginId: string; -} - -interface BuildFixedPanelTabIdArgs { - environmentId: string | null; - kind: FixedPanelTab["kind"]; - path: string; -} - -interface BuildWorkspaceFilePreviewTabIdArgs { - environmentId: string | null; - path: string; - projectId: string | null; -} - -interface BuildHostFilePreviewTabIdArgs { - environmentId: string | null; - path: string; - threadId: string | null; -} - -interface BuildThreadStorageFilePreviewTabIdArgs { - path: string; - threadId: string | null; -} - -interface NormalizeFixedPanelTabGroupStateResult { - activeTabId: string | null; - tabs: readonly FixedPanelTab[]; -} - function getLocalStorage(): Storage | null { if (typeof window === "undefined") { return null; @@ -465,189 +75,6 @@ function getLocalStorage(): Storage | null { return window.localStorage; } -function normalizeStorageSegment(value: string): string { - return encodeURIComponent(value.trim()); -} - -function decodeStorageSegment(value: string): string { - try { - return decodeURIComponent(value); - } catch { - return value; - } -} - -export function buildFixedPanelTabId({ - environmentId, - kind, - path, -}: BuildFixedPanelTabIdArgs): string { - return [ - kind, - encodeURIComponent(path), - encodeURIComponent( - environmentId ?? SECONDARY_PANEL_TAB_ID_ENVIRONMENT_NONE, - ), - ].join(":"); -} - -function buildWorkspaceFilePreviewTabId({ - environmentId, - path, - projectId, -}: BuildWorkspaceFilePreviewTabIdArgs): string { - return buildFixedPanelTabId({ - environmentId: environmentId ?? (projectId ? `project:${projectId}` : null), - kind: "workspace-file-preview", - path, - }); -} - -function buildHostFilePreviewTabId({ - environmentId, - path, - threadId, -}: BuildHostFilePreviewTabIdArgs): string { - if (threadId === null || environmentId === null) { - return buildFixedPanelTabId({ - environmentId: null, - kind: "host-file-preview", - path, - }); - } - return buildFixedPanelTabId({ - environmentId: `thread:${threadId}:environment:${environmentId}`, - kind: "host-file-preview", - path, - }); -} - -function buildThreadStorageFilePreviewTabId({ - path, - threadId, -}: BuildThreadStorageFilePreviewTabIdArgs): string { - return buildFixedPanelTabId({ - environmentId: threadId === null ? null : `thread:${threadId}`, - kind: "thread-storage-file-preview", - path, - }); -} - -export function createThreadInfoFixedPanelTab(): ThreadInfoFixedPanelTab { - return { - id: THREAD_INFO_TAB_ID, - kind: "thread-info", - }; -} - -export function createGitDiffFixedPanelTab(): GitDiffFixedPanelTab { - return { - id: GIT_DIFF_TAB_ID, - kind: "git-diff", - }; -} - -export function createPluginPageFixedPanelTab({ - fixedTabId, - pageId, - pluginId, -}: CreatePluginPageFixedPanelTabArgs): PluginPageFixedPanelTab { - return { - fixedTabId, - id: buildFixedPanelTabId({ - environmentId: null, - kind: "plugin-page-fixed", - path: `${pluginId}:${pageId}:${fixedTabId}`, - }), - kind: "plugin-page-fixed", - pageId, - pluginId, - }; -} - -export function createPluginPanelFixedPanelTab({ - actionId, - paramsJson, - pluginId, - title, -}: CreatePluginPanelFixedPanelTabArgs): PluginPanelFixedPanelTab { - return { - actionId, - // Params are part of the identity (title is not): re-opening the same - // action with the same params focuses the existing tab, different - // params open a sibling tab. - id: buildFixedPanelTabId({ - environmentId: null, - kind: "plugin-panel", - path: `${pluginId}:${actionId}:${paramsJson ?? ""}`, - }), - kind: "plugin-panel", - paramsJson, - pluginId, - title, - }; -} - -export function createWorkspaceFilePreviewFixedPanelTab({ - environmentId, - projectId, - tab, -}: CreateWorkspaceFilePreviewFixedPanelTabArgs): WorkspaceFilePreviewFixedPanelTab { - return { - environmentId, - id: buildWorkspaceFilePreviewTabId({ - environmentId, - path: tab.path, - projectId, - }), - kind: "workspace-file-preview", - lineRange: tab.lineRange, - path: tab.path, - projectId, - source: tab.source, - statusLabel: tab.statusLabel, - }; -} - -export function createHostFilePreviewFixedPanelTab({ - environmentId, - tab, - threadId, -}: CreateHostFilePreviewFixedPanelTabArgs): HostFilePreviewFixedPanelTab { - return { - environmentId, - id: buildHostFilePreviewTabId({ - environmentId, - path: tab.path, - threadId, - }), - kind: "host-file-preview", - lineRange: tab.lineRange, - path: tab.path, - threadId, - }; -} - -export function createThreadStorageFilePreviewFixedPanelTab({ - environmentId, - isPinned, - tab, - threadId, -}: CreateThreadStorageFilePreviewFixedPanelTabArgs): ThreadStorageFilePreviewFixedPanelTab { - return { - environmentId, - id: buildThreadStorageFilePreviewTabId({ - path: tab.path, - threadId, - }), - isPinned, - kind: "thread-storage-file-preview", - lineRange: tab.lineRange, - path: tab.path, - threadId, - }; -} - /** * Browser tabs get a fresh unique id per instance — the URL is mutable (it * changes on every navigation), so it cannot serve as a stable identity the way @@ -671,378 +98,6 @@ export function createBrowserFixedPanelTab({ }; } -export function createNewTabFixedPanelTab(): NewTabFixedPanelTab { - return { - id: NEW_TAB_TAB_ID, - kind: "new-tab", - }; -} - -/** - * Runtime invariant shared by every fixed secondary-panel host: an open panel - * always has an active tab. Keep a persisted active tab when it still exists, - * fall back to the first surviving tab when it does not, and close the panel - * when hydration leaves no tabs to show. - */ -export function ensureOpenFixedPanelHasActiveTab( - state: FixedPanelTabsState, -): FixedPanelTabsState { - if (!state.secondary.isOpen) { - return state; - } - - const activeTab = state.secondary.tabs.find( - (tab) => tab.id === state.secondary.activeTabId, - ); - if (activeTab !== undefined) { - return state; - } - - const fallbackTab = state.secondary.tabs[0]; - if (fallbackTab === undefined) { - return { - ...state, - secondary: { - ...state.secondary, - activeTabId: null, - isOpen: false, - }, - }; - } - - return { - ...state, - secondary: { - ...state.secondary, - activeTabId: fallbackTab.id, - }, - }; -} - -export function createTerminalFixedPanelTab({ - terminalId, - target, -}: CreateTerminalFixedPanelTabArgs): TerminalFixedPanelTab { - return { - id: buildFixedPanelTabId({ - environmentId: null, - kind: "terminal", - path: terminalId, - }), - kind: "terminal", - terminalId, - ...(target !== undefined ? { target } : {}), - }; -} - -function normalizeFixedPanelTabId(tab: FixedPanelTab): FixedPanelTab { - switch (tab.kind) { - case "thread-info": - return tab.id === THREAD_INFO_TAB_ID - ? tab - : { - ...tab, - id: THREAD_INFO_TAB_ID, - }; - case "git-diff": - return tab.id === GIT_DIFF_TAB_ID - ? tab - : { - ...tab, - id: GIT_DIFF_TAB_ID, - }; - case "plugin-page-fixed": { - const id = createPluginPageFixedPanelTab({ - fixedTabId: tab.fixedTabId, - pageId: tab.pageId, - pluginId: tab.pluginId, - }).id; - return tab.id === id ? tab : { ...tab, id }; - } - case "workspace-file-preview": { - const id = buildWorkspaceFilePreviewTabId({ - environmentId: tab.environmentId, - path: tab.path, - projectId: tab.projectId, - }); - return tab.id === id ? tab : { ...tab, id }; - } - case "host-file-preview": { - const id = buildHostFilePreviewTabId({ - environmentId: tab.environmentId, - path: tab.path, - threadId: tab.threadId, - }); - return tab.id === id ? tab : { ...tab, id }; - } - case "thread-storage-file-preview": { - const id = buildThreadStorageFilePreviewTabId({ - path: tab.path, - threadId: tab.threadId, - }); - return tab.id === id ? tab : { ...tab, id }; - } - case "browser": { - const idSegments = tab.id.split(":"); - const browserPath = - idSegments.length === 3 && idSegments[0] === "browser" - ? decodeStorageSegment(idSegments[1] ?? "") - : tab.id; - const id = buildFixedPanelTabId({ - environmentId: tab.environmentId, - kind: tab.kind, - path: browserPath, - }); - return tab.id === id ? tab : { ...tab, id }; - } - case "new-tab": - return tab.id === NEW_TAB_TAB_ID - ? tab - : { - ...tab, - id: NEW_TAB_TAB_ID, - }; - case "plugin-panel": { - const id = createPluginPanelFixedPanelTab({ - actionId: tab.actionId, - paramsJson: tab.paramsJson, - pluginId: tab.pluginId, - title: tab.title, - }).id; - return tab.id === id ? tab : { ...tab, id }; - } - case "terminal": { - const id = buildFixedPanelTabId({ - environmentId: null, - kind: tab.kind, - path: tab.terminalId, - }); - return tab.id === id ? tab : { ...tab, id }; - } - } -} - -function isTransientFixedPanelTab(tab: FixedPanelTab): boolean { - return tab.kind === "new-tab"; -} - -function normalizeFixedPanelTabGroupState({ - group, -}: NormalizeFixedPanelTabGroupStateArgs): NormalizeFixedPanelTabGroupStateResult { - const seenTabIds = new Set(); - const tabs: FixedPanelTab[] = []; - let activeTabId: string | null = null; - for (const tab of group.tabs) { - const normalizedTab = normalizeFixedPanelTabId(tab); - if ( - isTransientFixedPanelTab(normalizedTab) || - seenTabIds.has(normalizedTab.id) - ) { - continue; - } - seenTabIds.add(normalizedTab.id); - tabs.push(normalizedTab); - if ( - group.activeTabId !== null && - (tab.id === group.activeTabId || normalizedTab.id === group.activeTabId) - ) { - activeTabId = normalizedTab.id; - } - } - - return { - tabs, - activeTabId, - }; -} - -function normalizeFixedSecondaryPanelTabGroupState( - group: FixedSecondaryPanelTabGroupState, -): FixedSecondaryPanelTabGroupState { - return { - ...normalizeFixedPanelTabGroupState({ - group, - }), - isOpen: group.isOpen, - }; -} - -function stripTransientFixedPanelTabForStorage( - tab: FixedPanelTab, -): FixedPanelTab { - switch (tab.kind) { - case "workspace-file-preview": - case "host-file-preview": - case "thread-storage-file-preview": - return { - ...tab, - lineRange: null, - }; - case "thread-info": - case "git-diff": - case "plugin-page-fixed": - case "browser": - case "new-tab": - case "terminal": - return tab; - case "plugin-panel": - return tab.fileOpenerOwner === undefined - ? tab - : { - ...tab, - fileOpenerOwner: stripFileOpenerOwnerForStorage( - tab.fileOpenerOwner, - ), - }; - } -} - -function stripFileOpenerOwnerForStorage( - owner: ThreadTabFileOpenerOwner, -): ThreadTabFileOpenerOwner { - switch (owner.kind) { - case "workspace-file-preview": - return { ...owner, tab: { ...owner.tab, lineRange: null } }; - case "host-file-preview": - return { ...owner, tab: { ...owner.tab, lineRange: null } }; - case "thread-storage-file-preview": - return { ...owner, tab: { ...owner.tab, lineRange: null } }; - } -} - -function stripTransientFixedPanelTabsStateForStorage({ - state, -}: StripTransientFixedPanelTabsStateForStorageArgs): FixedPanelTabsState { - return { - ...state, - secondary: { - ...state.secondary, - tabs: state.secondary.tabs.map(stripTransientFixedPanelTabForStorage), - }, - }; -} - -export function normalizeFixedPanelTabsState({ - state, -}: NormalizeFixedPanelTabsStateArgs): FixedPanelTabsState { - const normalizedSecondary = normalizeFixedSecondaryPanelTabGroupState( - state.secondary, - ); - - return { - version: state.version, - secondary: normalizedSecondary, - lastUsedAt: state.lastUsedAt, - }; -} - -export function createEmptyFixedPanelTabsState( - args: CreateFixedPanelTabsStateArgs = {}, -): FixedPanelTabsState { - return normalizeFixedPanelTabsState({ - state: { - version: FIXED_PANEL_TABS_STATE_STORAGE_VERSION, - secondary: args.secondary ?? { - tabs: [], - activeTabId: null, - isOpen: false, - }, - lastUsedAt: args.lastUsedAt ?? 0, - }, - }); -} - -export const EMPTY_FIXED_PANEL_TABS_STATE = createEmptyFixedPanelTabsState(); - -export function getFixedPanelTabsStateStorageKey({ - threadId, -}: FixedPanelTabsStorageKeyArgs): string { - return `${FIXED_PANEL_TABS_STATE_STORAGE_PREFIX}-${normalizeStorageSegment( - threadId, - )}-${FIXED_PANEL_TABS_STATE_STORAGE_VERSION}`; -} - -export function isFixedPanelTabsStateStorageKey(key: string): boolean { - return key.startsWith(`${FIXED_PANEL_TABS_STATE_STORAGE_PREFIX}-`); -} - -export function isFixedPanelTabsStateExpired({ - now, - state, -}: IsFixedPanelTabsStateExpiredArgs): boolean { - return now - state.lastUsedAt > FIXED_PANEL_TABS_IDLE_EXPIRY_MS; -} - -export function parseFixedPanelTabsState({ - initialValue, - now, - storedValue, -}: ParseFixedPanelTabsStateArgs): FixedPanelTabsState { - return parseFixedPanelTabsStateForStorage({ - initialValue, - now, - storedValue, - }).state; -} - -function parseFixedPanelTabsStateForStorage({ - initialValue, - now, - storedValue, -}: ParseFixedPanelTabsStateArgs): ParseFixedPanelTabsStateForStorageResult { - if (storedValue === null) { - return { - shouldPrune: false, - state: initialValue, - }; - } - - let parsedValue: unknown; - try { - parsedValue = JSON.parse(storedValue); - } catch { - return { - shouldPrune: true, - state: initialValue, - }; - } - - const stateResult = fixedPanelTabsStateSchema.safeParse(parsedValue); - if (!stateResult.success) { - return { - shouldPrune: true, - state: initialValue, - }; - } - - const normalizedState = stripTransientFixedPanelTabsStateForStorage({ - state: normalizeFixedPanelTabsState({ - state: stateResult.data, - }), - }); - if (isFixedPanelTabsStateExpired({ now, state: normalizedState })) { - return { - shouldPrune: true, - state: initialValue, - }; - } - - return { - shouldPrune: false, - state: ensureOpenFixedPanelHasActiveTab(normalizedState), - }; -} - -export function serializeFixedPanelTabsState({ - state, -}: SerializeFixedPanelTabsStateArgs): string { - return JSON.stringify( - stripTransientFixedPanelTabsStateForStorage({ - state: normalizeFixedPanelTabsState({ state }), - }), - ); -} - export function pruneFixedPanelTabsStorage({ now, }: PruneFixedPanelTabsStorageArgs): void { @@ -1065,150 +120,3 @@ export function pruneFixedPanelTabsStorage({ } } } - -/** - * Prune decision for one stored blob. The idle-expiry check reads only - * `lastUsedAt` from the parsed JSON, so an expired blob (the common case in a - * long-lived browser profile) is dropped without a full schema parse; only - * blobs that are still fresh, or that carry no usable timestamp, go through - * the schema. - */ -function shouldPruneStoredFixedPanelTabsState( - storedValue: string | null, - now: number, -): boolean { - if (storedValue === null) { - return false; - } - let parsedValue: unknown; - try { - parsedValue = JSON.parse(storedValue); - } catch { - return true; - } - if (typeof parsedValue !== "object" || parsedValue === null) { - return true; - } - const lastUsedAt = Reflect.get(parsedValue, "lastUsedAt"); - if ( - typeof lastUsedAt === "number" && - Number.isInteger(lastUsedAt) && - lastUsedAt >= 0 - ) { - if (now - lastUsedAt > FIXED_PANEL_TABS_IDLE_EXPIRY_MS) { - return true; - } - return !fixedPanelTabsStateSchema.safeParse(parsedValue).success; - } - return parseFixedPanelTabsStateForStorage({ - initialValue: EMPTY_FIXED_PANEL_TABS_STATE, - now, - storedValue, - }).shouldPrune; -} - -export function areFixedPanelTabsEquivalent( - a: FixedPanelTab, - b: FixedPanelTab, -): boolean { - if (a.id !== b.id || a.kind !== b.kind) { - return false; - } - switch (a.kind) { - case "thread-info": - case "git-diff": - case "new-tab": - return true; - case "plugin-page-fixed": - return ( - b.kind === "plugin-page-fixed" && - a.pluginId === b.pluginId && - a.pageId === b.pageId && - a.fixedTabId === b.fixedTabId - ); - case "plugin-panel": - return ( - b.kind === "plugin-panel" && - a.pluginId === b.pluginId && - a.actionId === b.actionId && - a.paramsJson === b.paramsJson && - areFileOpenerOwnersEqual(a.fileOpenerOwner, b.fileOpenerOwner) && - a.title === b.title - ); - case "workspace-file-preview": - return ( - b.kind === "workspace-file-preview" && - a.environmentId === b.environmentId && - areFilePreviewLineRangesEqual({ - a: a.lineRange, - b: b.lineRange, - }) && - a.path === b.path && - a.projectId === b.projectId && - areEnvironmentFilePreviewSourcesEqual(a.source, b.source) && - a.statusLabel === b.statusLabel - ); - case "host-file-preview": - return ( - b.kind === "host-file-preview" && - a.environmentId === b.environmentId && - areFilePreviewLineRangesEqual({ - a: a.lineRange, - b: b.lineRange, - }) && - a.path === b.path && - a.threadId === b.threadId - ); - case "browser": - return ( - b.kind === "browser" && - a.environmentId === b.environmentId && - a.url === b.url && - a.title === b.title - ); - case "thread-storage-file-preview": - return ( - b.kind === "thread-storage-file-preview" && - a.environmentId === b.environmentId && - a.isPinned === b.isPinned && - areFilePreviewLineRangesEqual({ - a: a.lineRange, - b: b.lineRange, - }) && - a.path === b.path && - a.threadId === b.threadId - ); - case "terminal": - return ( - b.kind === "terminal" && - a.terminalId === b.terminalId && - JSON.stringify(a.target) === JSON.stringify(b.target) - ); - } -} - -function areFileOpenerOwnersEqual( - a: ThreadTabFileOpenerOwner | undefined, - b: ThreadTabFileOpenerOwner | undefined, -): boolean { - if (a === undefined || b === undefined) return a === b; - if ( - a.kind !== b.kind || - a.environmentId !== b.environmentId || - a.threadId !== b.threadId || - a.tab.path !== b.tab.path || - !areFilePreviewLineRangesEqual({ - a: a.tab.lineRange, - b: b.tab.lineRange, - }) - ) { - return false; - } - if (a.kind !== "workspace-file-preview") return true; - return ( - b.kind === "workspace-file-preview" && - a.projectId === b.projectId && - areEnvironmentFilePreviewSourcesEqual(a.tab.source, b.tab.source) && - a.tab.statusLabel === b.tab.statusLabel - ); -} diff --git a/apps/app/src/lib/fork-thread-request.ts b/apps/app/src/lib/fork-thread-request.ts index 14221b025f..9ef3966faa 100644 --- a/apps/app/src/lib/fork-thread-request.ts +++ b/apps/app/src/lib/fork-thread-request.ts @@ -1,87 +1,10 @@ -import type { - PermissionMode, - PromptInput, - ReasoningLevel, - ServiceTier, - Thread, -} from "@bb/domain"; -import type { AppCreateThreadRequest } from "@/lib/api-types"; - -export const FORK_THREAD_CREATE_SEED_LOCATION_STATE_KEY = - "forkThreadCreateSeed"; - -export interface ForkThreadCreateSeed { - environmentId: string; - model: string; - permissionMode: PermissionMode; - projectId: string; - providerId: string; - reasoningLevel: ReasoningLevel; - serviceTier: ServiceTier | undefined; - sourceSeqEnd: number | undefined; - sourceThreadId: string; - sourceThreadTitle: string; -} - -export interface BuildForkThreadRequestArgs extends ForkThreadCreateSeed { - input: PromptInput[]; - /** - * The source thread provider's `capabilities.supportsFork`, read from the - * server-provided ProviderInfo (execution-options query data). False when - * the provider is unknown or its data has not loaded — graceful absence. - */ - providerSupportsFork: boolean; -} - -type ForkableThread = Pick; - -export function isThreadForkable( - sourceThread: ForkableThread | null, - providerSupportsFork: boolean, -): boolean { - if (sourceThread === null || sourceThread.environmentId === null) { - return false; - } - return providerSupportsFork; -} - -export function buildForkThreadRequest({ - environmentId, - input, - model, - permissionMode, - projectId, - providerId, - providerSupportsFork, - reasoningLevel, - serviceTier, - sourceSeqEnd, - sourceThreadId, -}: BuildForkThreadRequestArgs): AppCreateThreadRequest | null { - if ( - !isThreadForkable( - { - environmentId, - providerId, - }, - providerSupportsFork, - ) - ) { - return null; - } - - return { - environment: { type: "reuse", environmentId }, - input, - model, - originKind: "fork", - permissionMode, - projectId, - providerId, - reasoningLevel, - ...(serviceTier ? { serviceTier } : {}), - ...(sourceSeqEnd !== undefined ? { sourceSeqEnd } : {}), - sourceThreadId, - startedOnBehalfOf: null, - }; -} +// Moved to @bb/client-core (shared with the native app); re-exported here so web imports keep resolving. +export { + FORK_THREAD_CREATE_SEED_LOCATION_STATE_KEY, + isThreadForkable, + buildForkThreadRequest, +} from "@bb/client-core"; +export type { + ForkThreadCreateSeed, + BuildForkThreadRequestArgs, +} from "@bb/client-core"; diff --git a/apps/app/src/lib/localhost-link-rewrite-preference.ts b/apps/app/src/lib/localhost-link-rewrite-preference.ts index 0698422f7b..3d0d683d77 100644 --- a/apps/app/src/lib/localhost-link-rewrite-preference.ts +++ b/apps/app/src/lib/localhost-link-rewrite-preference.ts @@ -1,48 +1,17 @@ import { useAtom } from "jotai"; +import { + REWRITE_LOCALHOST_LINKS_DEFAULT, + REWRITE_LOCALHOST_LINKS_STORAGE_KEY, +} from "@bb/client-core"; import { createBooleanPreferenceAtom } from "./browser-storage"; -export const REWRITE_LOCALHOST_LINKS_STORAGE_KEY = "bb.rewriteLocalhostLinks"; - -export const REWRITE_LOCALHOST_LINKS_DEFAULT = true; - -interface RewriteLocalhostLinkHrefArgs { - currentHostname: string | undefined; - enabled: boolean; - href: string | undefined; -} - -const LOOPBACK_LINK_HOSTNAMES = new Set(["127.0.0.1", "localhost"]); - -function isRewriteableLoopbackLink(url: URL): boolean { - return ( - (url.protocol === "http:" || url.protocol === "https:") && - LOOPBACK_LINK_HOSTNAMES.has(url.hostname.toLowerCase()) - ); -} - -export function rewriteLocalhostLinkHref({ - currentHostname, - enabled, - href, -}: RewriteLocalhostLinkHrefArgs): string | undefined { - if (!enabled || href === undefined || currentHostname === undefined) { - return href; - } - - let url: URL; - try { - url = new URL(href); - } catch { - return href; - } - - if (!isRewriteableLoopbackLink(url)) { - return href; - } - - url.hostname = currentHostname; - return url.toString(); -} +// The pure rewrite rule and preference constants live in @bb/client-core so the +// native markdown renderer applies the same rewrite; the jotai atom stays here. +export { + REWRITE_LOCALHOST_LINKS_DEFAULT, + REWRITE_LOCALHOST_LINKS_STORAGE_KEY, + rewriteLocalhostLinkHref, +} from "@bb/client-core"; export const rewriteLocalhostLinksPreferenceAtom = createBooleanPreferenceAtom( REWRITE_LOCALHOST_LINKS_STORAGE_KEY, diff --git a/apps/app/src/lib/neighbor-reorder.ts b/apps/app/src/lib/neighbor-reorder.ts index 94a88d9765..2433ae0dad 100644 --- a/apps/app/src/lib/neighbor-reorder.ts +++ b/apps/app/src/lib/neighbor-reorder.ts @@ -1,116 +1,11 @@ -export interface NeighborReorderItem { - id: string; -} - -export interface NeighborReorderRequest { - itemId: string; - nextItemId: string | null; - previousItemId: string | null; -} - -export interface BuildNeighborReorderRequestArgs< - Item extends NeighborReorderItem, -> { - activeId: string; - items: readonly Item[]; - overId: string; -} - -export interface ApplyNeighborReorderArgs { - items: readonly Item[]; - request: NeighborReorderRequest; -} - -interface MoveItemArgs { - fromIndex: number; - items: readonly Item[]; - toIndex: number; -} - -function moveItem({ - fromIndex, - items, - toIndex, -}: MoveItemArgs): Item[] { - const result = [...items]; - const movedItems = result.splice(fromIndex, 1); - const movedItem = movedItems[0]; - if (!movedItem) { - return result; - } - result.splice(toIndex, 0, movedItem); - return result; -} - -export function buildNeighborReorderRequest({ - activeId, - items, - overId, -}: BuildNeighborReorderRequestArgs): NeighborReorderRequest | null { - if (activeId === overId) { - return null; - } - - const oldIndex = items.findIndex((item) => item.id === activeId); - const newIndex = items.findIndex((item) => item.id === overId); - if (oldIndex === -1 || newIndex === -1) { - return null; - } - - const reorderedItems = moveItem({ - items, - fromIndex: oldIndex, - toIndex: newIndex, - }); - const movedIndex = reorderedItems.findIndex((item) => item.id === activeId); - if (movedIndex === -1) { - return null; - } - - return { - itemId: activeId, - previousItemId: reorderedItems[movedIndex - 1]?.id ?? null, - nextItemId: reorderedItems[movedIndex + 1]?.id ?? null, - }; -} - -export function applyNeighborReorder({ - items, - request, -}: ApplyNeighborReorderArgs): Item[] { - const movedIndex = items.findIndex((item) => item.id === request.itemId); - if (movedIndex === -1) { - return [...items]; - } - - const movedItem = items[movedIndex]; - if (!movedItem) { - return [...items]; - } - const remainingItems = items.filter((item) => item.id !== request.itemId); - let insertIndex = 0; - - if (request.previousItemId !== null) { - const previousIndex = remainingItems.findIndex( - (item) => item.id === request.previousItemId, - ); - if (previousIndex === -1) { - return [...items]; - } - insertIndex = previousIndex + 1; - } else if (request.nextItemId !== null) { - const nextIndex = remainingItems.findIndex( - (item) => item.id === request.nextItemId, - ); - if (nextIndex === -1) { - return [...items]; - } - insertIndex = nextIndex; - } - - return [ - ...remainingItems.slice(0, insertIndex), - movedItem, - ...remainingItems.slice(insertIndex), - ]; -} +// Moved to @bb/client-core (shared with the native app); re-exported here so web imports keep resolving. +export { + buildNeighborReorderRequest, + applyNeighborReorder, +} from "@bb/client-core"; +export type { + NeighborReorderItem, + NeighborReorderRequest, + BuildNeighborReorderRequestArgs, + ApplyNeighborReorderArgs, +} from "@bb/client-core"; diff --git a/apps/app/src/lib/optimistic-timeline-row.ts b/apps/app/src/lib/optimistic-timeline-row.ts index b621f5a5c5..0a251ef32b 100644 --- a/apps/app/src/lib/optimistic-timeline-row.ts +++ b/apps/app/src/lib/optimistic-timeline-row.ts @@ -1,15 +1,5 @@ -/** - * Client-only timeline rows: the user message a send renders immediately, - * before the server's own row for it exists. - * - * An optimistic row lives in the timeline query cache and is dropped from it - * by the refetch that brings the server's row. Because its id is minted - * locally it can never match a server row id, so any consumer that merges - * cached snapshots has to treat it as non-durable — a retained copy would sit - * alongside the server row instead of being replaced by it. - */ -export const OPTIMISTIC_TIMELINE_ROW_ID_PREFIX = "optimistic-user-"; - -export function isOptimisticTimelineRowId(id: string): boolean { - return id.startsWith(OPTIMISTIC_TIMELINE_ROW_ID_PREFIX); -} +// Moved to @bb/client-core (shared with the native app); re-exported here so web imports keep resolving. +export { + OPTIMISTIC_TIMELINE_ROW_ID_PREFIX, + isOptimisticTimelineRowId, +} from "@bb/client-core"; diff --git a/apps/app/src/lib/permission-mode-options.ts b/apps/app/src/lib/permission-mode-options.ts index a66dbf0ba8..57fcc60c06 100644 --- a/apps/app/src/lib/permission-mode-options.ts +++ b/apps/app/src/lib/permission-mode-options.ts @@ -1,29 +1,3 @@ -import type { PermissionMode } from "@bb/domain"; -import type { PickerOption } from "@/components/pickers/OptionPicker"; - -/** - * The permission modes as the user sees them. Shared by the composer pickers - * (which pick the mode a thread runs with) and Settings → Machines (which - * picks the highest mode a machine allows), so the two never drift. - */ -export const PERMISSION_MODE_OPTIONS: PickerOption[] = [ - { - value: "accept-edits", - label: "Accept Edits", - description: - "Applies edits inside the workspace automatically. Anything beyond the workspace asks you first.", - }, - { - value: "auto", - label: "Approve for me", - description: - "Same workspace sandbox, with requests reviewed automatically. High-risk actions can still come back to you.", - }, - { - value: "full", - label: "Full Access", - tone: "warning", - description: - "No sandbox and no approvals — the agent can run anything on your machine.", - }, -]; +// Moved to @bb/client-core (shared with the native app); re-exported here so web imports keep resolving. +export { PERMISSION_MODE_OPTIONS } from "@bb/client-core"; +export type { PermissionModeOption } from "@bb/client-core"; diff --git a/apps/app/src/lib/plugin-mention-triggers.ts b/apps/app/src/lib/plugin-mention-triggers.ts index 8040072fbb..438d0ba773 100644 --- a/apps/app/src/lib/plugin-mention-triggers.ts +++ b/apps/app/src/lib/plugin-mention-triggers.ts @@ -1,44 +1,8 @@ -export type PluginMentionTrigger = "@" | "#" | "$" | "!" | "~"; - -export const DEFAULT_PLUGIN_MENTION_TRIGGER: PluginMentionTrigger = "@"; -export const PLUGIN_MENTION_TRIGGER_VALUES = [ - "@", - "#", - "$", - "!", - "~", -] as const satisfies readonly PluginMentionTrigger[]; - -export function isPluginMentionTrigger( - value: unknown, -): value is PluginMentionTrigger { - switch (value) { - case "@": - case "#": - case "$": - case "!": - case "~": - return true; - default: - return false; - } -} - -export function normalizePluginMentionTriggers( - value: unknown, -): readonly PluginMentionTrigger[] | null { - if (value === undefined) { - return [DEFAULT_PLUGIN_MENTION_TRIGGER]; - } - if (!Array.isArray(value) || value.length === 0) { - return null; - } - const triggers: PluginMentionTrigger[] = []; - for (const trigger of value) { - if (!isPluginMentionTrigger(trigger) || triggers.includes(trigger)) { - return null; - } - triggers.push(trigger); - } - return triggers; -} +// Moved to @bb/client-core (shared with the native app); re-exported here so web imports keep resolving. +export { + DEFAULT_PLUGIN_MENTION_TRIGGER, + PLUGIN_MENTION_TRIGGER_VALUES, + isPluginMentionTrigger, + normalizePluginMentionTriggers, +} from "@bb/client-core"; +export type { PluginMentionTrigger } from "@bb/client-core"; diff --git a/apps/app/src/lib/prompt-draft.ts b/apps/app/src/lib/prompt-draft.ts index 66419d407d..4e1ae6c94c 100644 --- a/apps/app/src/lib/prompt-draft.ts +++ b/apps/app/src/lib/prompt-draft.ts @@ -1,393 +1,14 @@ -import { - promptTextMentionSchema, - type PromptInput, - type PromptTextMention, -} from "@bb/domain"; -import { - uploadedPromptAttachmentSchema, - type UploadedPromptAttachment, -} from "@bb/server-contract"; -import { z } from "zod"; -import { - isAutomationPromptCommandResource, - SUBMITTED_AUTOMATION_PROMPT_PREFIX, -} from "./automation-prompt"; - -export type PromptDraftAttachment = UploadedPromptAttachment; - -export interface PromptDraftState { - text: string; - mentions: PromptTextMention[]; - attachments: PromptDraftAttachment[]; -} - -const promptDraftStorageSchema = z.object({ - text: z.string().default(""), - mentions: z - .array(z.unknown()) - .default([]) - .transform((items) => - items.flatMap((item) => { - const result = promptTextMentionSchema.safeParse(item); - return result.success ? [result.data] : []; - }), - ), - attachments: z - .array(z.unknown()) - .default([]) - .transform((items) => - items.flatMap((item) => { - const result = uploadedPromptAttachmentSchema.safeParse(item); - return result.success ? [result.data] : []; - }), - ), -}); - -export function emptyPromptDraftState(): PromptDraftState { - return { - text: "", - mentions: [], - attachments: [], - }; -} - -function normalizeQuotedSelectionText(text: string): string { - const lines = text.replace(/\r\n|\r/gu, "\n").split("\n"); - const normalizedLines: string[] = []; - - for (let index = 0; index < lines.length; index += 1) { - const line = lines[index]!; - const previousLine = normalizedLines.at(-1); - const nextLine = lines[index + 1]; - if ( - line.trim().length === 0 && - previousLine?.startsWith(">") === true && - nextLine?.startsWith(">") === true - ) { - continue; - } - normalizedLines.push(line); - } - - return normalizedLines.join("\n").trim(); -} - -/** - * Append a quoted selection to the draft text as a `> `-prefixed blockquote - * block. The editor parses these blocks into real blockquote nodes; the user - * types their reply in the paragraph below. Appending to the END of the text - * keeps every existing mention offset unchanged. - */ -export function appendQuoteToDraftText( - state: PromptDraftState, - quotedText: string, -): PromptDraftState { - // Guard the boundary: an empty/whitespace-only selection would otherwise - // emit a bare "> " block and make an empty draft look dirty. - const trimmed = normalizeQuotedSelectionText(quotedText); - if (trimmed === "") return state; - - const block = trimmed - .split("\n") - .map((line) => (line.length > 0 ? `> ${line}` : ">")) - .join("\n"); - - // Trailing newline so the reply paragraph sits below the quote. - const text = state.text === "" ? `${block}\n` : `${state.text}\n${block}\n`; - - return { ...state, text }; -} - -export function appendQuoteAndAttachmentsToDraft( - state: PromptDraftState, - quotedText: string, - attachments: readonly PromptDraftAttachment[], -): PromptDraftState { - const quotedState = appendQuoteToDraftText(state, quotedText); - if (attachments.length === 0) { - return quotedState; - } - - const existingAttachmentPaths = new Set( - quotedState.attachments.map((attachment) => attachment.path), - ); - const mergedAttachments = [...quotedState.attachments]; - for (const attachment of attachments) { - if (existingAttachmentPaths.has(attachment.path)) { - continue; - } - existingAttachmentPaths.add(attachment.path); - mergedAttachments.push(attachment); - } - - if (mergedAttachments.length === quotedState.attachments.length) { - return quotedState; - } - - return { ...quotedState, attachments: mergedAttachments }; -} - -export function isPromptDraftEmpty(draft: PromptDraftState): boolean { - return ( - draft.text.length === 0 && - draft.mentions.length === 0 && - draft.attachments.length === 0 - ); -} - -export function parsePromptDraftStorage( - rawValue: string | null, -): PromptDraftState { - if (!rawValue) return emptyPromptDraftState(); - - try { - const parsed: unknown = JSON.parse(rawValue); - const result = promptDraftStorageSchema.safeParse(parsed); - return result.success ? result.data : emptyPromptDraftState(); - } catch { - return emptyPromptDraftState(); - } -} - -export function serializePromptDraftStorage( - draft: PromptDraftState, -): string | null { - const text = draft.text; - const mentions = draft.mentions; - const attachments = draft.attachments; - if (isPromptDraftEmpty(draft)) { - return null; - } - return JSON.stringify({ - text, - ...(mentions.length > 0 ? { mentions } : {}), - attachments, - }); -} - -export function arePromptDraftStatesEqual( - left: PromptDraftState, - right: PromptDraftState, -): boolean { - return ( - serializePromptDraftStorage(left) === serializePromptDraftStorage(right) - ); -} - -function getFileNameFromPath(path: string): string { - const trimmedPath = path.trim(); - if (trimmedPath.length === 0) { - return "Attachment"; - } - - const segments = trimmedPath.split("/"); - const lastSegment = segments[segments.length - 1]; - return lastSegment && lastSegment.length > 0 ? lastSegment : trimmedPath; -} - -function normalizePromptTextMentions( - mentions: readonly PromptTextMention[], - textLength: number, -): PromptTextMention[] { - return mentions - .filter( - (mention) => - mention.start >= 0 && - mention.end > mention.start && - mention.end <= textLength, - ) - .sort((left, right) => left.start - right.start || left.end - right.end); -} - -interface ExpandedPromptText { - text: string; - mentions: PromptTextMention[]; -} - -function expandAutomationPromptCommandMentions( - text: string, - mentions: readonly PromptTextMention[], -): ExpandedPromptText { - const automationMentions = mentions - .filter((mention) => isAutomationPromptCommandResource(mention.resource)) - .sort((left, right) => left.start - right.start || left.end - right.end); - - if (automationMentions.length === 0) { - return { text, mentions: [...mentions] }; - } - - const replacements: Array<{ start: number; end: number }> = []; - let cursor = 0; - let nextText = ""; - for (const mention of automationMentions) { - if (mention.start < cursor) { - continue; - } - replacements.push({ start: mention.start, end: mention.end }); - nextText += text.slice(cursor, mention.start); - nextText += SUBMITTED_AUTOMATION_PROMPT_PREFIX; - cursor = mention.end; - } - nextText += text.slice(cursor); - - const nextMentions = mentions.flatMap((mention) => { - if (isAutomationPromptCommandResource(mention.resource)) { - return []; - } - - let offset = 0; - for (const replacement of replacements) { - if (mention.start < replacement.end && mention.end > replacement.start) { - return []; - } - if (replacement.end <= mention.start) { - offset += - SUBMITTED_AUTOMATION_PROMPT_PREFIX.length - - (replacement.end - replacement.start); - } - } - - return [ - { - ...mention, - start: mention.start + offset, - end: mention.end + offset, - }, - ]; - }); - - return { - text: nextText, - mentions: normalizePromptTextMentions(nextMentions, nextText.length), - }; -} - -export function promptDraftToInput(draft: PromptDraftState): PromptInput[] { - const input: PromptInput[] = []; - - const trimStartLength = draft.text.length - draft.text.trimStart().length; - const trimEndIndex = draft.text.trimEnd().length; - const text = draft.text.slice(trimStartLength, trimEndIndex); - if (text.length > 0) { - const mentions = normalizePromptTextMentions( - draft.mentions.flatMap((mention) => { - const visibleStart = Math.max(mention.start, trimStartLength); - const visibleEnd = Math.min(mention.end, trimEndIndex); - return visibleStart < visibleEnd - ? [ - { - ...mention, - start: visibleStart - trimStartLength, - end: visibleEnd - trimStartLength, - }, - ] - : []; - }), - text.length, - ); - const expandedText = expandAutomationPromptCommandMentions(text, mentions); - input.push({ - type: "text", - text: expandedText.text, - mentions: expandedText.mentions, - }); - } - - for (const attachment of draft.attachments) { - if (attachment.type === "localImage") { - input.push({ - type: "localImage", - path: attachment.path, - }); - continue; - } - - input.push({ - type: "localFile", - path: attachment.path, - name: attachment.name, - ...(attachment.sizeBytes > 0 ? { sizeBytes: attachment.sizeBytes } : {}), - ...(attachment.mimeType ? { mimeType: attachment.mimeType } : {}), - }); - } - - return input; -} - -export function promptInputToDraft( - input: readonly PromptInput[], -): PromptDraftState { - const textSegments: string[] = []; - const mentions: PromptTextMention[] = []; - const attachments: PromptDraftState["attachments"] = []; - let textOffset = 0; - - for (const chunk of input) { - if (chunk.type === "text") { - if (chunk.text.trim().length > 0) { - if (textSegments.length > 0) { - textOffset += 2; - } - for (const mention of chunk.mentions) { - if ( - mention.start >= 0 && - mention.end > mention.start && - mention.end <= chunk.text.length - ) { - mentions.push({ - ...mention, - start: textOffset + mention.start, - end: textOffset + mention.end, - }); - } - } - textSegments.push(chunk.text); - textOffset += chunk.text.length; - } - continue; - } - - if (chunk.type === "localImage") { - attachments.push({ - type: "localImage", - path: chunk.path, - name: getFileNameFromPath(chunk.path), - sizeBytes: 0, - }); - continue; - } - - if (chunk.type === "localFile") { - attachments.push({ - type: "localFile", - path: chunk.path, - name: chunk.name ?? getFileNameFromPath(chunk.path), - sizeBytes: chunk.sizeBytes ?? 0, - ...(chunk.mimeType ? { mimeType: chunk.mimeType } : {}), - }); - } - } - - return { - text: textSegments.join("\n\n"), - mentions, - attachments, - }; -} - -export function getProjectStoredPromptAttachmentPaths( - attachments: readonly PromptDraftAttachment[], -): string[] { - return [ - ...new Set( - attachments.flatMap((attachment) => { - const path = attachment.path; - const isRuntimeReadable = - /^[\\/]/u.test(path) || - /^[a-zA-Z]:[\\/]/u.test(path) || - /^[a-zA-Z][a-zA-Z0-9+.-]*:/u.test(path); - return isRuntimeReadable ? [] : [path]; - }), - ), - ]; -} +// Moved to @bb/client-core (shared with the native app); re-exported here so web imports keep resolving. +export { + emptyPromptDraftState, + appendQuoteToDraftText, + appendQuoteAndAttachmentsToDraft, + isPromptDraftEmpty, + parsePromptDraftStorage, + serializePromptDraftStorage, + arePromptDraftStatesEqual, + promptDraftToInput, + promptInputToDraft, + getProjectStoredPromptAttachmentPaths, +} from "@bb/client-core"; +export type { PromptDraftAttachment, PromptDraftState } from "@bb/client-core"; diff --git a/apps/app/src/lib/route-paths.ts b/apps/app/src/lib/route-paths.ts index 7829bae2fa..29c826a8b7 100644 --- a/apps/app/src/lib/route-paths.ts +++ b/apps/app/src/lib/route-paths.ts @@ -1,255 +1,15 @@ -import { PERSONAL_PROJECT_ID } from "@bb/domain"; import { matchPath } from "react-router-dom"; +import { + PLUGIN_PANEL_ROUTE_PATH, + ROUTE_PATTERNS, + TOOLS_ROUTE_PATH, + stripRoutePathSuffix, +} from "@bb/client-core"; -export const APP_ROOT_ROUTE_PATH = "/"; -export const AUTH_CALLBACK_ROUTE_PATH = "/auth/callback"; -export const SETTINGS_ROUTE_PATH = "/settings"; -// Settings buckets (general, files, …) plus legacy plugin routes that redirect -// to the canonical Extensions → Plugins surfaces. The static "plugins" segment must -// win over :section so those old deep links resolve before redirecting. -export const SETTINGS_SECTION_ROUTE_PATH = "/settings/:section"; -export const SETTINGS_PLUGINS_ROUTE_PATH = "/settings/plugins"; -export const SETTINGS_PLUGIN_ROUTE_PATH = "/settings/plugins/:pluginId"; -export const SETTINGS_PROVIDER_ROUTE_PATH = "/settings/providers/:providerId"; -// Per-machine detail page. The static "machines" segment sits above the -// :section route, which has no splat and so never matches this two-segment path. -export const SETTINGS_MACHINE_ROUTE_PATH = "/settings/machines/:hostId"; -export const TOOLS_ROUTE_PATH = "/extensions"; -export const TOOLS_SKILLS_ROUTE_PATH = "/extensions/skills"; -export const TOOLS_SKILL_DETAIL_ROUTE_PATH = - "/extensions/skills/library/:skillId"; -export const LEGACY_TOOLS_SKILL_DETAIL_ROUTE_PATH = - "/extensions/skills/installed/:skillId"; -export const TOOLS_REGISTRY_SKILLS_ROUTE_PATH = "/extensions/skills/registry"; -export const TOOLS_REGISTRY_SKILL_DETAIL_ROUTE_PATH = - "/extensions/skills/registry/:registrySkillId"; -export const TOOLS_PLUGINS_ROUTE_PATH = "/extensions/plugins"; -export const TOOLS_PLUGIN_BROWSE_ROUTE_PATH = "/extensions/plugins/browse"; -export const TOOLS_PLUGIN_DETAIL_ROUTE_PATH = "/extensions/plugins/:pluginId"; -// The pre-rename Extensions prefix. Every /tools URL redirects to the same -// path under /extensions, so old deep links keep working. -export const LEGACY_TOOLS_PREFIX_ROUTE_PATH = "/tools"; -export const LEGACY_TOOLS_SPLAT_ROUTE_PATH = "/tools/*"; -export const LEGACY_TOOLS_AUTOMATIONS_ROUTE_PATH = "/tools/automations"; -export const LEGACY_TOOLS_AUTOMATION_BROWSE_ROUTE_PATH = - "/tools/automations/browse"; -export const LEGACY_TOOLS_AUTOMATION_DETAIL_ROUTE_PATH = - "/tools/automations/:projectId/:automationId"; -export const LEGACY_TOOLS_AUTOMATION_EDIT_ROUTE_PATH = - "/tools/automations/:projectId/:automationId/edit"; -export const LEGACY_SKILLS_ROUTE_PATH = "/skills"; -export const LEGACY_AUTOMATIONS_ROUTE_PATH = "/automations"; -export const LEGACY_AUTOMATION_DETAIL_ROUTE_PATH = - "/automations/:projectId/:automationId"; -export const AUTOMATIONS_PLUGIN_ID = "automations"; -export const AUTOMATIONS_PLUGIN_PANEL_PATH = "automations"; -export const AUTOMATIONS_ROUTE_PATH = "/plugins/automations/automations"; -export const AUTOMATIONS_BROWSE_ROUTE_PATH = - "/plugins/automations/automations/browse"; -export const AUTOMATION_DETAIL_ROUTE_PATH = - "/plugins/automations/automations/:projectId/:automationId"; -export const AUTOMATION_EDIT_ROUTE_PATH = - "/plugins/automations/automations/:projectId/:automationId/edit"; -export const SKILLS_ROUTE_PATH = TOOLS_SKILLS_ROUTE_PATH; -export const ROOT_COMPOSE_ROUTE_PATH = APP_ROOT_ROUTE_PATH; -export const LEGACY_PROJECT_COMPOSE_ROUTE_PATH = "/projects/:projectId"; -export const PROJECTLESS_ARCHIVED_ROUTE_PATH = "/archived"; -export const PROJECTLESS_THREAD_DETAIL_ROUTE_PATH = "/threads/:threadId"; -export const PROJECT_SETTINGS_ROUTE_PATH = "/projects/:projectId/settings"; -export const PROJECT_ARCHIVED_ROUTE_PATH = "/projects/:projectId/archived"; -export const THREAD_DETAIL_ROUTE_PATH = - "/projects/:projectId/threads/:threadId"; -// Trailing splat: the remainder is the panel's `subPath` (empty at the root). -export const PLUGIN_PANEL_ROUTE_PATH = "/plugins/:pluginId/:panelPath/*"; - -/** The plugin whose panel `pathname` shows, or null off the panel route. */ -export function getPluginPanelRoutePluginId(pathname: string): string | null { - return matchPath(PLUGIN_PANEL_ROUTE_PATH, pathname)?.params.pluginId ?? null; -} - -export interface ThreadRoutePathArgs { - projectId: string; - threadId: string; -} - -export interface IsRoutePathArgs { - path: string; -} - -export interface ResolveRouteHrefArgs { - currentOrigin: string; - href: string; -} - -export interface RouteHrefResolution { - path: string; -} - -export function isProjectlessProjectId( - projectId: string | null | undefined, -): boolean { - return projectId === PERSONAL_PROJECT_ID; -} - -export function getRootComposeRoutePath(): string { - return ROOT_COMPOSE_ROUTE_PATH; -} - -export function getLegacyProjectComposeRoutePath(projectId: string): string { - return `/projects/${projectId}`; -} - -// Opens a project's compose view. The personal project has no `/projects/:id` -// surface — its compose view is the app root — so it routes there instead. -export function getProjectComposeRoutePath(projectId: string): string { - return isProjectlessProjectId(projectId) - ? getRootComposeRoutePath() - : getLegacyProjectComposeRoutePath(projectId); -} - -export function getSettingsRoutePath(section?: string): string { - return section === undefined - ? SETTINGS_ROUTE_PATH - : `/settings/${encodeURIComponent(section)}`; -} - -export function getSettingsProviderRoutePath(providerId: string): string { - return `/settings/providers/${encodeURIComponent(providerId)}`; -} - -export function getSettingsMachineRoutePath(hostId: string): string { - return `/settings/machines/${encodeURIComponent(hostId)}`; -} - -/** - * True on Extensions and every canonical route nested under it. Legacy /tools - * URLs return false: they only exist long enough to redirect, and the - * automations ones leave Extensions entirely for their plugin-owned panel. - */ -export function isToolsRoutePath(pathname: string): boolean { - return ( - pathname === TOOLS_ROUTE_PATH || - matchPath(`${TOOLS_ROUTE_PATH}/*`, pathname) !== null - ); -} - -export function getSkillsRoutePath(): string { - return SKILLS_ROUTE_PATH; -} - -export function getRegistrySkillsRoutePath(): string { - return TOOLS_REGISTRY_SKILLS_ROUTE_PATH; -} - -export interface SkillDetailRoutePathArgs { - skillId: string; -} - -export function getSkillDetailRoutePath({ - skillId, -}: SkillDetailRoutePathArgs): string { - return `${TOOLS_SKILLS_ROUTE_PATH}/library/${encodeURIComponent(skillId)}`; -} - -export interface RegistrySkillDetailRoutePathArgs { - registrySkillId: string; -} - -export function getRegistrySkillDetailRoutePath({ - registrySkillId, -}: RegistrySkillDetailRoutePathArgs): string { - return `${TOOLS_SKILLS_ROUTE_PATH}/registry/${encodeURIComponent( - registrySkillId, - )}`; -} - -export function getPluginsRoutePath(): string { - return TOOLS_PLUGINS_ROUTE_PATH; -} - -export interface PluginDetailRoutePathArgs { - pluginId: string; - view?: "installed"; -} - -export function getPluginDetailRoutePath({ - pluginId, - view, -}: PluginDetailRoutePathArgs): string { - const path = `${TOOLS_PLUGINS_ROUTE_PATH}/${encodeURIComponent(pluginId)}`; - return view === "installed" ? `${path}?view=installed` : path; -} - -/** - * A plugin's configuration lives on the Settings page; the Extensions detail - * page links here instead of hosting the form. - */ -export function getPluginConfigurationRoutePath( - args: PluginDetailRoutePathArgs, -): string { - return `/settings/plugins/${encodeURIComponent(args.pluginId)}`; -} - -export function getAutomationsRoutePath(): string { - return AUTOMATIONS_ROUTE_PATH; -} - -export interface AutomationDetailRoutePathArgs { - projectId: string; - automationId: string; -} - -export function getAutomationDetailRoutePath({ - projectId, - automationId, -}: AutomationDetailRoutePathArgs): string { - return `${AUTOMATIONS_ROUTE_PATH}/${encodeURIComponent( - projectId, - )}/${encodeURIComponent(automationId)}`; -} - -export function getAutomationEditRoutePath( - args: AutomationDetailRoutePathArgs, -): string { - return `${getAutomationDetailRoutePath(args)}/edit`; -} - -export function getProjectSettingsRoutePath(projectId: string): string { - return `/projects/${projectId}/settings`; -} - -export interface PluginPanelRoutePathArgs { - pluginId: string; - /** The nav panel's registered `path` segment (validated: [a-zA-Z0-9_-]+). */ - path: string; - /** Location inside the panel; segments are encoded, slashes preserved. */ - subPath?: string; -} - -export function getPluginPanelRoutePath({ - pluginId, - path, - subPath, -}: PluginPanelRoutePathArgs): string { - const root = `/plugins/${encodeURIComponent(pluginId)}/${encodeURIComponent(path)}`; - if (subPath === undefined || subPath === "") { - return root; - } - const encoded = subPath - .split("/") - .filter((segment) => segment.length > 0) - .map((segment) => encodeURIComponent(segment)) - .join("/"); - return encoded.length > 0 ? `${root}/${encoded}` : root; -} - -export function getThreadRoutePath(args: ThreadRoutePathArgs): string { - return isProjectlessProjectId(args.projectId) - ? `/threads/${args.threadId}` - : `/projects/${args.projectId}/threads/${args.threadId}`; -} - -const baseRoutePatterns: readonly string[] = [ +// Route constants and path builders live in @bb/client-core so the native app +// can build the same links; re-exported here so web imports keep resolving. +// Only the react-router `matchPath` consumers stay in this file. +export { APP_ROOT_ROUTE_PATH, AUTH_CALLBACK_ROUTE_PATH, SETTINGS_ROUTE_PATH, @@ -257,6 +17,7 @@ const baseRoutePatterns: readonly string[] = [ SETTINGS_PLUGINS_ROUTE_PATH, SETTINGS_PLUGIN_ROUTE_PATH, SETTINGS_PROVIDER_ROUTE_PATH, + SETTINGS_MACHINE_ROUTE_PATH, TOOLS_ROUTE_PATH, TOOLS_SKILLS_ROUTE_PATH, TOOLS_SKILL_DETAIL_ROUTE_PATH, @@ -275,37 +36,87 @@ const baseRoutePatterns: readonly string[] = [ LEGACY_SKILLS_ROUTE_PATH, LEGACY_AUTOMATIONS_ROUTE_PATH, LEGACY_AUTOMATION_DETAIL_ROUTE_PATH, + AUTOMATIONS_PLUGIN_ID, + AUTOMATIONS_PLUGIN_PANEL_PATH, AUTOMATIONS_ROUTE_PATH, AUTOMATIONS_BROWSE_ROUTE_PATH, AUTOMATION_DETAIL_ROUTE_PATH, AUTOMATION_EDIT_ROUTE_PATH, + SKILLS_ROUTE_PATH, + ROOT_COMPOSE_ROUTE_PATH, LEGACY_PROJECT_COMPOSE_ROUTE_PATH, PROJECTLESS_ARCHIVED_ROUTE_PATH, + PROJECTLESS_THREAD_DETAIL_ROUTE_PATH, PROJECT_SETTINGS_ROUTE_PATH, PROJECT_ARCHIVED_ROUTE_PATH, - PROJECTLESS_THREAD_DETAIL_ROUTE_PATH, THREAD_DETAIL_ROUTE_PATH, PLUGIN_PANEL_ROUTE_PATH, -]; + ROUTE_PATTERNS, + isProjectlessProjectId, + getRootComposeRoutePath, + getLegacyProjectComposeRoutePath, + getProjectComposeRoutePath, + getSettingsRoutePath, + getSettingsProviderRoutePath, + getSettingsMachineRoutePath, + getSkillsRoutePath, + getRegistrySkillsRoutePath, + getSkillDetailRoutePath, + getRegistrySkillDetailRoutePath, + getPluginsRoutePath, + getPluginDetailRoutePath, + getPluginConfigurationRoutePath, + getAutomationsRoutePath, + getAutomationDetailRoutePath, + getAutomationEditRoutePath, + getProjectSettingsRoutePath, + getPluginPanelRoutePath, + getThreadRoutePath, + stripRoutePathSuffix, +} from "@bb/client-core"; +export type { + ThreadRoutePathArgs, + SkillDetailRoutePathArgs, + RegistrySkillDetailRoutePathArgs, + PluginDetailRoutePathArgs, + AutomationDetailRoutePathArgs, + PluginPanelRoutePathArgs, +} from "@bb/client-core"; -export const ROUTE_PATTERNS = baseRoutePatterns; +/** The plugin whose panel `pathname` shows, or null off the panel route. */ +export function getPluginPanelRoutePluginId(pathname: string): string | null { + return matchPath(PLUGIN_PANEL_ROUTE_PATH, pathname)?.params.pluginId ?? null; +} -const ABSOLUTE_HTTP_URL_PATTERN = /^https?:\/\//iu; +export interface IsRoutePathArgs { + path: string; +} + +export interface ResolveRouteHrefArgs { + currentOrigin: string; + href: string; +} + +export interface RouteHrefResolution { + path: string; +} -function stripPathSuffix(path: string): string { - const queryIndex = path.indexOf("?"); - const hashIndex = path.indexOf("#"); - const suffixIndex = - queryIndex === -1 - ? hashIndex - : hashIndex === -1 - ? queryIndex - : Math.min(queryIndex, hashIndex); - return suffixIndex === -1 ? path : path.slice(0, suffixIndex); +/** + * True on Extensions and every canonical route nested under it. Legacy /tools + * URLs return false: they only exist long enough to redirect, and the + * automations ones leave Extensions entirely for their plugin-owned panel. + */ +export function isToolsRoutePath(pathname: string): boolean { + return ( + pathname === TOOLS_ROUTE_PATH || + matchPath(`${TOOLS_ROUTE_PATH}/*`, pathname) !== null + ); } +const ABSOLUTE_HTTP_URL_PATTERN = /^https?:\/\//iu; + export function isRoutePath({ path }: IsRoutePathArgs): boolean { - const pathname = stripPathSuffix(path); + const pathname = stripRoutePathSuffix(path); return ROUTE_PATTERNS.some( (pattern) => matchPath(pattern, pathname) !== null, ); diff --git a/apps/app/src/lib/thread-activity.ts b/apps/app/src/lib/thread-activity.ts index e55506c455..3838c97494 100644 --- a/apps/app/src/lib/thread-activity.ts +++ b/apps/app/src/lib/thread-activity.ts @@ -1,301 +1,20 @@ -import { assertNever } from "@bb/core-ui"; -import type { Thread, ThreadListEntry, ThreadWithRuntime } from "@bb/domain"; -// Imported from the defining leaf module, not the timeline barrel: the sidebar -// thread list reaches this helper before first paint, and the barrel would pull -// the whole timeline (and @pierre/diffs, Shiki, KaTeX behind it) onto the boot -// path for one predicate. -import { isRunningThreadRuntimeDisplayStatus } from "@/components/thread/timeline/thread-runtime-status.js"; -import { isThreadRead } from "@/lib/thread-read-state"; - -type ThreadStatusShape = Pick< - Thread, - "status" | "lastReadAt" | "latestAttentionAt" | "parentThreadId" ->; - -type ThreadRuntimeShape = Pick; -type ThreadActivityStateShape = Pick; - -export function isRuntimeBusyThread(thread: ThreadRuntimeShape): boolean { - return isRunningThreadRuntimeDisplayStatus(thread.runtime.displayStatus); -} - -export function hasActiveWorkflowActivity( - thread: ThreadActivityStateShape, -): boolean { - return thread.activity.activeWorkflowCount > 0; -} - -export function hasActiveBackgroundAgentActivity( - thread: ThreadActivityStateShape, -): boolean { - return thread.activity.activeBackgroundAgentCount > 0; -} - -export function hasActiveBackgroundCommandActivity( - thread: ThreadActivityStateShape, -): boolean { - return thread.activity.activeBackgroundCommandCount > 0; -} - -export function hasActivePlanModeActivity( - thread: ThreadActivityStateShape, -): boolean { - return thread.activity.activePlanModeCount > 0; -} - -export function hasActiveGoalActivity( - thread: ThreadActivityStateShape, -): boolean { - return thread.activity.activeGoalCount > 0; -} - -export interface ThreadListIndicatorState { - hasPendingInteraction: boolean; - hasUnsubmittedDraft: boolean; - hasUnreadError: boolean; - hasUnreadSuccess: boolean; - isBackgroundAgentActive: boolean; - isBackgroundCommandActive: boolean; - isGoalActive: boolean; - isPlanModeActive: boolean; - isRuntimeActive: boolean; - isWorkflowActive: boolean; -} - -export type ThreadListIndicatorKind = - | "unread-error" - | "waiting-for-input" - | "working-draft" - | "workflow" - | "background-agent" - | "background-command" - | "plan-mode" - | "goal" - | "runtime" - | "draft" - | "unread-success" - | "none"; - -const THREAD_LIST_INDICATOR_LABELS: Record< - Exclude, - string -> = { - "unread-error": "Unread thread failed", - "waiting-for-input": "Thread needs user input", - "working-draft": "Thread working with unsubmitted draft", - workflow: "Workflow running", - "background-agent": "Background agent running", - "background-command": "Background command running", - "plan-mode": "Plan mode active", - goal: "Goal active", - runtime: "Thread working", - draft: "Thread has unsubmitted draft", - "unread-success": "Unread thread succeeded", -}; - -export function getThreadListIndicatorLabel( - kind: ThreadListIndicatorKind, -): string | null { - return kind === "none" ? null : THREAD_LIST_INDICATOR_LABELS[kind]; -} - -/** - * Whether a thread-list row has active work, independent of which status wins - * the single trailing indicator slot. Attention states such as unread errors - * and pending input can outrank background work visually without making that - * work stop; split membership uses this predicate to retain its shimmer. - */ -export function hasThreadListWorkingActivity( - state: ThreadListIndicatorState, - hasRunningPluginStatus = false, -): boolean { - return ( - state.isRuntimeActive || - state.isWorkflowActive || - state.isBackgroundAgentActive || - state.isBackgroundCommandActive || - state.isPlanModeActive || - state.isGoalActive || - hasRunningPluginStatus - ); -} - -/** - * Resolves the one trailing indicator slot from independent, unsuppressed - * thread state. Keep all precedence here so every thread-list surface makes - * the same choice when activities overlap. - */ -export function resolveThreadListIndicator( - state: ThreadListIndicatorState, -): ThreadListIndicatorKind { - // Attention states come first: the runtime stays active for the whole time a - // question or approval is open, so ranking "runtime" above them would hide the - // one state the user can act on behind a spinner that never resolves on its - // own. Plan and goal outrank the spinner too — they describe how the current - // turn is running, and their glyphs shimmer, so they already read as working. - // Only ambient work the row can't otherwise explain sits below the spinner. - if (state.hasUnreadError) return "unread-error"; - if (state.hasPendingInteraction) return "waiting-for-input"; - - const hasActiveWork = hasThreadListWorkingActivity(state); - if (state.hasUnsubmittedDraft && hasActiveWork) return "working-draft"; - if (state.isPlanModeActive) return "plan-mode"; - if (state.isGoalActive) return "goal"; - if (state.isRuntimeActive) return "runtime"; - if (state.isWorkflowActive) return "workflow"; - if (state.isBackgroundAgentActive) return "background-agent"; - if (state.isBackgroundCommandActive) return "background-command"; - if (state.hasUnsubmittedDraft) return "draft"; - if (state.hasUnreadSuccess) return "unread-success"; - return "none"; -} - -/** - * The signals a collapsed parent row surfaces on behalf of its hidden children. - * A collapsed row renders these through its single trailing status glyph, using - * the same priority as a leaf row through `resolveThreadListIndicator`. - * Expanded rows show their own status, - * since the children are then visible with their own glyphs. Background - * agent, command, and workflow work are tracked separately from runtime work so - * the sidebar can use task-specific signals instead of collapsing them into a - * generic spinner. - */ -export interface CollapsedChildActivity { - /** At least one child is blocked on the user (needs input). */ - pending: boolean; - /** At least one child is actively working, including workflow work. */ - working: boolean; - /** At least one child has an unsubmitted composer draft. */ - hasUnsubmittedDraft: boolean; - /** At least one child is actively running a foreground/runtime turn. */ - runtimeWorking: boolean; - /** At least one idle child has a provider workflow still running. */ - workflow: boolean; - /** At least one child has a background agent or subagent still running. */ - backgroundAgent: boolean; - /** At least one child has a background shell command still running. */ - backgroundCommand: boolean; - /** At least one child is showing the plan-mode banner above the composer. */ - planMode: boolean; - /** At least one child is showing the active-goal banner above the composer. */ - goal: boolean; - /** At least one successfully finished child is unread. */ - unread: boolean; - /** At least one unread child has reached the terminal error state. */ - unreadError: boolean; -} - -export const NO_COLLAPSED_CHILD_ACTIVITY: CollapsedChildActivity = { - pending: false, - working: false, - hasUnsubmittedDraft: false, - runtimeWorking: false, - workflow: false, - backgroundAgent: false, - backgroundCommand: false, - planMode: false, - goal: false, - unread: false, - unreadError: false, -}; - -type ThreadActivityShape = ThreadStatusShape & - ThreadRuntimeShape & - Pick; - -const EMPTY_DRAFT_THREAD_IDS: ReadonlySet = new Set(); - -/** Rolls a child thread list up to the set of activity signals present in it. */ -export function getCollapsedChildActivity( - threads: readonly ThreadActivityShape[], - draftThreadIds: ReadonlySet = EMPTY_DRAFT_THREAD_IDS, -): CollapsedChildActivity { - let pending = false; - let working = false; - let hasUnsubmittedDraft = false; - let runtimeWorking = false; - let workflow = false; - let backgroundAgent = false; - let backgroundCommand = false; - let planMode = false; - let goal = false; - let unread = false; - let unreadError = false; - for (const thread of threads) { - if (draftThreadIds.has(thread.id)) { - hasUnsubmittedDraft = true; - } - const childUnreadDone = isUnreadDoneThread(thread); - if (childUnreadDone && thread.status === "error") { - unreadError = true; - } else if (childUnreadDone) { - unread = true; - } - - if (thread.hasPendingInteraction) { - pending = true; - } - const childRuntimeWorking = isRuntimeBusyThread(thread); - const childWorkflowActive = hasActiveWorkflowActivity(thread); - const childBackgroundAgentActive = hasActiveBackgroundAgentActivity(thread); - const childBackgroundCommandActive = - hasActiveBackgroundCommandActivity(thread); - const childPlanModeActive = hasActivePlanModeActivity(thread); - const childGoalActive = hasActiveGoalActivity(thread); - if (childRuntimeWorking) { - runtimeWorking = true; - working = true; - } - if (childWorkflowActive) { - workflow = true; - working = true; - } - if (childBackgroundAgentActive) { - backgroundAgent = true; - working = true; - } - if (childBackgroundCommandActive) { - backgroundCommand = true; - working = true; - } - if (childPlanModeActive) { - planMode = true; - working = true; - } - if (childGoalActive) { - goal = true; - working = true; - } - } - return { - pending, - working, - hasUnsubmittedDraft, - runtimeWorking, - workflow, - backgroundAgent, - backgroundCommand, - planMode, - goal, - unread, - unreadError, - }; -} - -export function isUnreadDoneThread(thread: ThreadStatusShape): boolean { - if (thread.parentThreadId != null) { - return false; - } - - switch (thread.status) { - case "error": - case "idle": - return !isThreadRead(thread); - case "active": - case "starting": - case "stopping": - return false; - default: - return assertNever(thread.status); - } -} +// Moved to @bb/client-core (shared with the native app); re-exported here so web imports keep resolving. +export { + isRuntimeBusyThread, + hasActiveWorkflowActivity, + hasActiveBackgroundAgentActivity, + hasActiveBackgroundCommandActivity, + hasActivePlanModeActivity, + hasActiveGoalActivity, + getThreadListIndicatorLabel, + hasThreadListWorkingActivity, + resolveThreadListIndicator, + NO_COLLAPSED_CHILD_ACTIVITY, + getCollapsedChildActivity, + isUnreadDoneThread, +} from "@bb/client-core"; +export type { + ThreadListIndicatorState, + ThreadListIndicatorKind, + CollapsedChildActivity, +} from "@bb/client-core"; diff --git a/apps/app/src/lib/thread-handoff-request.ts b/apps/app/src/lib/thread-handoff-request.ts index c75a856903..42d5331596 100644 --- a/apps/app/src/lib/thread-handoff-request.ts +++ b/apps/app/src/lib/thread-handoff-request.ts @@ -1,90 +1,11 @@ -import type { PromptTextMention } from "@bb/domain"; -import type { PromptDraftState } from "@/lib/prompt-draft"; - -export const THREAD_HANDOFF_CREATE_SEED_LOCATION_STATE_KEY = - "threadHandoffCreateSeed"; - -export interface ThreadHandoffCreateSeed { - environmentId: string | null; - projectId: string; - sourceThreadId: string; - sourceThreadTitle: string; -} - -export interface ThreadHandoffLocationState { - focusPrompt: true; - reuseEnvironmentId?: string; - [THREAD_HANDOFF_CREATE_SEED_LOCATION_STATE_KEY]: ThreadHandoffCreateSeed; -} - -export function buildThreadHandoffLocationState( - seed: ThreadHandoffCreateSeed, -): ThreadHandoffLocationState { - return { - focusPrompt: true, - ...(seed.environmentId !== null - ? { reuseEnvironmentId: seed.environmentId } - : {}), - [THREAD_HANDOFF_CREATE_SEED_LOCATION_STATE_KEY]: seed, - }; -} - -export function readThreadHandoffCreateSeedFromLocationState( - state: unknown, -): ThreadHandoffCreateSeed | null { - if (!state || typeof state !== "object") return null; - const candidate = (state as Record)[ - THREAD_HANDOFF_CREATE_SEED_LOCATION_STATE_KEY - ]; - if (!candidate || typeof candidate !== "object") return null; - const value = candidate as Record; - if ( - typeof value.projectId !== "string" || - value.projectId.length === 0 || - typeof value.sourceThreadId !== "string" || - value.sourceThreadId.length === 0 || - typeof value.sourceThreadTitle !== "string" || - value.sourceThreadTitle.trim().length === 0 - ) { - return null; - } - if ( - value.environmentId !== undefined && - value.environmentId !== null && - typeof value.environmentId !== "string" - ) { - return null; - } - - const environmentId = - typeof value.environmentId === "string" && value.environmentId.length > 0 - ? value.environmentId - : null; - - return { - environmentId, - projectId: value.projectId, - sourceThreadId: value.sourceThreadId, - sourceThreadTitle: value.sourceThreadTitle.trim(), - }; -} - -export function buildThreadHandoffPromptDraft( - seed: ThreadHandoffCreateSeed, -): PromptDraftState { - const prefix = "Continue from "; - const mentionText = `@thread:${seed.sourceThreadId}`; - const text = `${prefix}${mentionText}`; - const mention: PromptTextMention = { - start: prefix.length, - end: prefix.length + mentionText.length, - resource: { - kind: "thread", - projectId: seed.projectId, - threadId: seed.sourceThreadId, - label: seed.sourceThreadTitle, - }, - }; - - return { text, mentions: [mention], attachments: [] }; -} +// Moved to @bb/client-core (shared with the native app); re-exported here so web imports keep resolving. +export { + THREAD_HANDOFF_CREATE_SEED_LOCATION_STATE_KEY, + buildThreadHandoffLocationState, + readThreadHandoffCreateSeedFromLocationState, + buildThreadHandoffPromptDraft, +} from "@bb/client-core"; +export type { + ThreadHandoffCreateSeed, + ThreadHandoffLocationState, +} from "@bb/client-core"; diff --git a/apps/app/src/lib/thread-read-state.ts b/apps/app/src/lib/thread-read-state.ts index 0db2f41f74..3fc3b91296 100644 --- a/apps/app/src/lib/thread-read-state.ts +++ b/apps/app/src/lib/thread-read-state.ts @@ -1,7 +1,3 @@ -import type { Thread } from "@bb/domain"; - -export type ThreadReadState = Pick; - -export function isThreadRead(thread: ThreadReadState): boolean { - return (thread.lastReadAt ?? 0) >= thread.latestAttentionAt; -} +// Moved to @bb/client-core (shared with the native app); re-exported here so web imports keep resolving. +export { isThreadRead } from "@bb/client-core"; +export type { ThreadReadState } from "@bb/client-core"; diff --git a/apps/app/src/views/thread-detail/threadDetailPromptSubmission.ts b/apps/app/src/views/thread-detail/threadDetailPromptSubmission.ts index f91ca623eb..ef5d418db0 100644 --- a/apps/app/src/views/thread-detail/threadDetailPromptSubmission.ts +++ b/apps/app/src/views/thread-detail/threadDetailPromptSubmission.ts @@ -1,306 +1,30 @@ -import type { - PermissionMode, - PromptInput, - ReasoningLevel, - ServiceTier, - ThreadRuntimeDisplayStatus, -} from "@bb/domain"; -import type { - CreateQueuedMessageRequest, - ExistingThreadExecutionInputSources, -} from "@bb/server-contract"; -import type { FollowUpSubmitMode } from "@/components/promptbox/FollowUpPromptBox"; -import type { SendMessageMutationRequest } from "./threadDetailMutationTypes"; - -export interface CreateQueuedFollowUpRequest extends CreateQueuedMessageRequest { - id: string; -} - -export interface SendQueuedMessageByIdRequest { - id: string; - mode: "auto"; - queuedMessageId: string; -} - -export interface ThreadExecutionSelection { - model: string; - permissionMode: PermissionMode; - reasoningLevel: ReasoningLevel; - serviceTier: ServiceTier | undefined; - supportsServiceTier: boolean; - executionInputSources: ExistingThreadExecutionInputSources; -} - -export type FollowUpExecutionSelection = ThreadExecutionSelection | null; - -interface SharedThreadExecutionRequestFields { - model?: string; - permissionMode?: PermissionMode; - reasoningLevel?: ReasoningLevel; - serviceTier?: ServiceTier; - executionInputSources?: ExistingThreadExecutionInputSources; -} - -interface BaseFollowUpRequestArgs { - input: PromptInput[]; - threadId: string; -} - -export interface BuildAutoFollowUpRequestArgs extends BaseFollowUpRequestArgs { - execution: FollowUpExecutionSelection; -} - -export interface BuildCreateQueuedFollowUpRequestArgs extends BaseFollowUpRequestArgs { - execution: FollowUpExecutionSelection; -} - -export interface BuildSendQueuedMessageByIdRequestArgs { - queuedMessageId: string; - threadId: string; -} - -export interface BuildFollowUpShortcutRequestArgs extends BaseFollowUpRequestArgs { - queuedMessages: readonly QueuedMessageForSend[]; -} - -export interface CanSubmitFollowUpShortcutArgs { - hasPromptDraftInput: boolean; - isFollowUpSubmitting: boolean; - isQueueMutationPending: boolean; - queuedMessageCount: number; - runtimeDisplayStatus: ThreadRuntimeDisplayStatus; - submitModeKind: FollowUpSubmitMode["kind"]; -} - -export interface BuildFollowUpSubmitModeArgs { - hasPendingInteraction: boolean; - isDefaultExecutionOptionsLoading: boolean; - isPendingInteractionsInitialLoading: boolean; - isStopRequested: boolean; - onStop: () => void; - runtimeDisplayStatus: ThreadRuntimeDisplayStatus; -} - -export interface BuildSideChatSubmitModeArgs { - childThreadId: string | null; - isDefaultExecutionOptionsLoading: boolean; - isStopRequested: boolean; - onStop: () => void; - runtimeDisplayStatus: ThreadRuntimeDisplayStatus; -} - -export interface ResolveDefaultExecutionOptionsStateArgs { - hasConcreteDefaultExecutionOptions: boolean; - hasResolvedDefaultExecutionOptions: boolean; - isError: boolean; -} - -export interface QueuedMessageForSend { - id: string; -} - -export type FollowUpShortcutRequest = - | { kind: "draft"; request: SendMessageMutationRequest } - | { kind: "queued"; request: SendQueuedMessageByIdRequest }; - -export type DefaultExecutionOptionsState = - | "available" - | "loading" - | "unavailable"; - -export function shouldQueueFollowUpMessage( - displayStatus: ThreadRuntimeDisplayStatus, -): boolean { - return ( - displayStatus === "active" || - displayStatus === "host-reconnecting" || - displayStatus === "provisioning" || - displayStatus === "starting" || - displayStatus === "waiting-for-host" - ); -} - -export function buildFollowUpSubmitMode({ - hasPendingInteraction, - isDefaultExecutionOptionsLoading, - isPendingInteractionsInitialLoading, - isStopRequested, - onStop, - runtimeDisplayStatus, -}: BuildFollowUpSubmitModeArgs): FollowUpSubmitMode { - if (isStopRequested) { - return { kind: "blocked", reason: "stopping" }; - } - if (isPendingInteractionsInitialLoading) { - return { kind: "blocked", reason: "loading-pending-interactions" }; - } - if (hasPendingInteraction) { - return { kind: "blocked", reason: "pending-interaction" }; - } - if (shouldQueueFollowUpMessage(runtimeDisplayStatus)) { - return { kind: "queue", onStop }; - } - if (isDefaultExecutionOptionsLoading) { - return { kind: "blocked", reason: "loading-execution-options" }; - } - return { kind: "ready" }; -} - -export function buildSideChatSubmitMode({ - childThreadId, - isDefaultExecutionOptionsLoading, - isStopRequested, - onStop, - runtimeDisplayStatus, -}: BuildSideChatSubmitModeArgs): FollowUpSubmitMode { - if (childThreadId === null) { - return isDefaultExecutionOptionsLoading - ? { kind: "blocked", reason: "loading-execution-options" } - : { kind: "ready" }; - } - return buildFollowUpSubmitMode({ - hasPendingInteraction: false, - isDefaultExecutionOptionsLoading, - isPendingInteractionsInitialLoading: false, - isStopRequested, - onStop, - runtimeDisplayStatus, - }); -} - -export function canSubmitFollowUpShortcut({ - hasPromptDraftInput, - isFollowUpSubmitting, - isQueueMutationPending, - queuedMessageCount, - runtimeDisplayStatus, - submitModeKind, -}: CanSubmitFollowUpShortcutArgs): boolean { - return ( - runtimeDisplayStatus === "active" && - submitModeKind === "queue" && - !isFollowUpSubmitting && - !isQueueMutationPending && - (queuedMessageCount > 0 || hasPromptDraftInput) - ); -} - -export function resolveDefaultExecutionOptionsState({ - hasConcreteDefaultExecutionOptions, - hasResolvedDefaultExecutionOptions, - isError, -}: ResolveDefaultExecutionOptionsStateArgs): DefaultExecutionOptionsState { - if (hasConcreteDefaultExecutionOptions) { - return "available"; - } - if (hasResolvedDefaultExecutionOptions || isError) { - return "unavailable"; - } - return "loading"; -} - -export function buildAutoFollowUpRequest({ - execution, - input, - threadId, -}: BuildAutoFollowUpRequestArgs): SendMessageMutationRequest | null { - if (input.length === 0) { - return null; - } - - return { - id: threadId, - input, - mode: "queue-if-active", - ...buildSharedThreadExecutionRequestFields(execution), - }; -} - -function buildSteerFollowUpRequest({ - input, - threadId, -}: BaseFollowUpRequestArgs): SendMessageMutationRequest | null { - if (input.length === 0) { - return null; - } - - return { - id: threadId, - input, - mode: "steer-if-active", - }; -} - -export function buildCreateQueuedFollowUpRequest({ - execution, - input, - threadId, -}: BuildCreateQueuedFollowUpRequestArgs): CreateQueuedFollowUpRequest | null { - if (input.length === 0) { - return null; - } - - return { - id: threadId, - input, - ...buildSharedThreadExecutionRequestFields(execution), - }; -} - -export function buildSendQueuedMessageByIdRequest({ - queuedMessageId, - threadId, -}: BuildSendQueuedMessageByIdRequestArgs): SendQueuedMessageByIdRequest { - return { - id: threadId, - mode: "auto", - queuedMessageId, - }; -} - -/** - * Cmd+Enter on an active follow-up composer sends current draft input as an - * explicit steer. If the composer is empty, it sends only the current queue - * head through the same auto path as the queued-card "Send now" action. - */ -export function buildFollowUpShortcutRequest({ - input, - queuedMessages, - threadId, -}: BuildFollowUpShortcutRequestArgs): FollowUpShortcutRequest | null { - const draftRequest = buildSteerFollowUpRequest({ input, threadId }); - if (draftRequest) { - return { kind: "draft", request: draftRequest }; - } - - const nextQueuedMessage = queuedMessages[0]; - if (!nextQueuedMessage) { - return null; - } - - return { - kind: "queued", - request: buildSendQueuedMessageByIdRequest({ - queuedMessageId: nextQueuedMessage.id, - threadId, - }), - }; -} - -function buildSharedThreadExecutionRequestFields( - execution: FollowUpExecutionSelection, -): SharedThreadExecutionRequestFields { - if (execution === null) { - return {}; - } - - return { - model: execution.model, - ...(execution.supportsServiceTier && execution.serviceTier - ? { serviceTier: execution.serviceTier } - : {}), - reasoningLevel: execution.reasoningLevel, - permissionMode: execution.permissionMode, - executionInputSources: execution.executionInputSources, - }; -} +// Moved to @bb/client-core (shared with the native app); re-exported here so web imports keep resolving. +export { + shouldQueueFollowUpMessage, + buildFollowUpSubmitMode, + buildSideChatSubmitMode, + canSubmitFollowUpShortcut, + resolveDefaultExecutionOptionsState, + buildAutoFollowUpRequest, + buildCreateQueuedFollowUpRequest, + buildSendQueuedMessageByIdRequest, + buildFollowUpShortcutRequest, +} from "@bb/client-core"; +export type { + SendMessageMutationRequest, + CreateQueuedFollowUpRequest, + SendQueuedMessageByIdRequest, + ThreadExecutionSelection, + FollowUpExecutionSelection, + BuildAutoFollowUpRequestArgs, + BuildCreateQueuedFollowUpRequestArgs, + BuildSendQueuedMessageByIdRequestArgs, + BuildFollowUpShortcutRequestArgs, + CanSubmitFollowUpShortcutArgs, + BuildFollowUpSubmitModeArgs, + BuildSideChatSubmitModeArgs, + ResolveDefaultExecutionOptionsStateArgs, + QueuedMessageForSend, + FollowUpShortcutRequest, + DefaultExecutionOptionsState, +} from "@bb/client-core"; diff --git a/apps/app/src/views/thread-detail/threadQueuedMessages.ts b/apps/app/src/views/thread-detail/threadQueuedMessages.ts index 5068b8a636..0123544267 100644 --- a/apps/app/src/views/thread-detail/threadQueuedMessages.ts +++ b/apps/app/src/views/thread-detail/threadQueuedMessages.ts @@ -1,91 +1,7 @@ -import { type PromptInput } from "@bb/domain"; -import { fileNameFromPath } from "@bb/thread-view"; -import { promptInputToDraft, type PromptDraftState } from "@/lib/prompt-draft"; - -const QUEUED_MESSAGE_PREVIEW_MAX_CHARS = 140; - -interface FormatQueuedMessagePreviewOptions { - truncate?: boolean; -} - -function visibleQueuedMessageInput( - input: readonly PromptInput[], -): PromptInput[] { - return input.filter((chunk) => chunk.visibility !== "agent-only"); -} - -function getAttachmentNameFromPath(path: string): string { - const trimmedPath = path.trim(); - if (trimmedPath.length === 0) return "Attachment"; - return fileNameFromPath(trimmedPath); -} - -export function countQueuedMessageAttachments( - input: readonly PromptInput[], -): number { - let count = 0; - for (const chunk of visibleQueuedMessageInput(input)) { - if (chunk.type === "localImage" || chunk.type === "localFile") { - count += 1; - } - } - return count; -} - -export function getQueuedMessageVisibleText( - input: readonly PromptInput[], -): string { - return visibleQueuedMessageInput(input) - .filter( - (chunk): chunk is Extract => - chunk.type === "text", - ) - .map((chunk) => chunk.text.trim()) - .filter((chunk) => chunk.length > 0) - .join("\n\n"); -} - -export function formatQueuedMessagePreview( - input: readonly PromptInput[], - options: FormatQueuedMessagePreviewOptions = {}, -): string { - const visibleInput = visibleQueuedMessageInput(input); - const text = getQueuedMessageVisibleText(visibleInput); - const trimmedText = text.replace(/\s+/g, " ").trim(); - if (trimmedText.length > 0) { - if ( - options.truncate === false || - trimmedText.length <= QUEUED_MESSAGE_PREVIEW_MAX_CHARS - ) { - return trimmedText; - } - return `${trimmedText.slice(0, QUEUED_MESSAGE_PREVIEW_MAX_CHARS - 1)}...`; - } - - const attachmentCount = countQueuedMessageAttachments(visibleInput); - if (attachmentCount === 1) { - const firstAttachment = visibleInput.find( - (chunk) => chunk.type === "localImage" || chunk.type === "localFile", - ); - if (firstAttachment) { - if (firstAttachment.type === "localFile" && firstAttachment.name) { - return `Attachment only (${firstAttachment.name})`; - } - return `Attachment only (${getAttachmentNameFromPath( - firstAttachment.path, - )})`; - } - return "Attachment only (1 file)"; - } - if (attachmentCount > 1) { - return `Attachment only (${attachmentCount} files)`; - } - - return "(empty message)"; -} - -export function queuedInputToDraft( - input: readonly PromptInput[], -): PromptDraftState { - return promptInputToDraft(visibleQueuedMessageInput(input)); -} +// Moved to @bb/client-core (shared with the native app); re-exported here so web imports keep resolving. +export { + countQueuedMessageAttachments, + getQueuedMessageVisibleText, + formatQueuedMessagePreview, + queuedInputToDraft, +} from "@bb/client-core"; diff --git a/packages/client-core/package.json b/packages/client-core/package.json new file mode 100644 index 0000000000..e8b8666c24 --- /dev/null +++ b/packages/client-core/package.json @@ -0,0 +1,32 @@ +{ + "name": "@bb/client-core", + "version": "0.0.1", + "type": "module", + "exports": { + ".": { + "source": "./src/index.ts", + "types": "./src/index.ts", + "default": "./src/index.ts" + } + }, + "types": "./src/index.ts", + "scripts": { + "clean": "rimraf dist tsconfig.tsbuildinfo", + "typecheck": "tsc --noEmit", + "test": "vitest run --config vitest.config.ts" + }, + "dependencies": { + "@bb/core-ui": "workspace:*", + "@bb/desktop-contract": "workspace:*", + "@bb/domain": "workspace:*", + "@bb/server-contract": "workspace:*", + "@bb/thread-view": "workspace:*", + "zod": "^4.3.6" + }, + "devDependencies": { + "@bb/tsconfig": "workspace:*", + "@types/node": "^22.0.0", + "typescript": "npm:@typescript/typescript6@^6.0.2", + "typescript-7": "npm:typescript@^7.0.2" + } +} diff --git a/packages/client-core/src/api-types.ts b/packages/client-core/src/api-types.ts new file mode 100644 index 0000000000..1242bd3d7e --- /dev/null +++ b/packages/client-core/src/api-types.ts @@ -0,0 +1,30 @@ +import type { ThreadOriginKind } from "@bb/domain"; +import type { CreateThreadRequest } from "@bb/server-contract"; + +export type AppCreateThreadRequest = Omit< + CreateThreadRequest, + "origin" | "startedOnBehalfOf" | "originKind" +> & + Partial>; + +export interface ThreadListFilters { + projectId?: string; + parentThreadId?: string; + sourceThreadId?: string; + /** Restrict to threads filed directly under this section. */ + sectionId?: string; + /** Restrict to loose threads — those not filed under any section. */ + unsectioned?: boolean; + hasParent?: boolean; + /** Restrict to threads spawned with this origin. */ + originKind?: ThreadOriginKind; + /** App callers must choose active or archived; server omission intentionally means both. */ + archived: boolean; + limit?: number; + offset?: number; +} + +export interface ThreadSearchFilters { + query: string; + limitPerGroup?: number; +} diff --git a/packages/client-core/src/codepoint-compare.ts b/packages/client-core/src/codepoint-compare.ts new file mode 100644 index 0000000000..7c7be9d5dc --- /dev/null +++ b/packages/client-core/src/codepoint-compare.ts @@ -0,0 +1,17 @@ +/** + * Compare two strings by Unicode codepoint, matching the server's SQLite binary + * `asc()` collation and the fractional-index key generator + * (`createOrderKeyBetween`, which compares with `<`/`>=`). Use this — not + * `String.localeCompare` — whenever client ordering of an order key (`sortKey`, + * `pinSortKey`) or an `id` must agree with the server, since `localeCompare` + * folds case and reorders letters vs. digits. + */ +export function compareCodepoint(left: string, right: string): number { + if (left < right) { + return -1; + } + if (left > right) { + return 1; + } + return 0; +} diff --git a/packages/client-core/src/diff/renderable-patch.ts b/packages/client-core/src/diff/renderable-patch.ts new file mode 100644 index 0000000000..59512f2e71 --- /dev/null +++ b/packages/client-core/src/diff/renderable-patch.ts @@ -0,0 +1,159 @@ +import type { TimelineFileChange } from "@bb/server-contract"; +import { + getFileChangeAction, + isPatchMetadataLine, + type FileChangeAction, +} from "@bb/thread-view"; + +/** + * Patch text a diff renderer can parse, synthesized from a timeline file + * change. Providers report file changes in several shapes — a full + * `diff --git` patch, hunk bodies with no header, or plain "+"/"-" content + * lines for created/deleted files — and every client normalizes them the same + * way before rendering. Line numbers are disabled for synthesized patches + * because their hunk headers are invented. + */ +export interface RenderablePatchText { + disableLineNumbers: boolean; + patch: string; +} + +type SyntheticPatchAction = "created" | "deleted"; + +function splitPatchLines(diff: string): string[] { + const normalizedDiff = diff.replaceAll("\r\n", "\n"); + if (normalizedDiff.length === 0) { + return []; + } + const lines = normalizedDiff.split("\n"); + const lastLine = lines[lines.length - 1]; + if (lastLine === "") { + lines.pop(); + } + return lines; +} + +function getPatchBodyLines(diff: string | null): string[] { + if (!diff) { + return []; + } + return splitPatchLines(diff).filter((line) => !isPatchMetadataLine(line)); +} + +function normalizePatchPath(path: string): string { + return path.replaceAll("\\", "/").replace(/^\/+/u, ""); +} + +function buildSyntheticPatchBodyLines( + lines: readonly string[], + action: SyntheticPatchAction, +): string[] { + const contentPrefix = action === "created" ? "+" : "-"; + const oppositePrefix = action === "created" ? "-" : "+"; + const bodyLines: string[] = []; + + for (const line of lines) { + if (line.startsWith(contentPrefix)) { + bodyLines.push(line); + continue; + } + if (line.startsWith(oppositePrefix) || line.startsWith(" ")) { + continue; + } + bodyLines.push(`${contentPrefix}${line}`); + } + + return bodyLines; +} + +function toSyntheticPatch( + change: TimelineFileChange, + action: SyntheticPatchAction, +): string | null { + const lines = getPatchBodyLines(change.diff); + if (lines.length === 0) return null; + const normalizedPath = normalizePatchPath(change.path); + const fromPath = action === "created" ? "/dev/null" : `a/${normalizedPath}`; + const toPath = action === "created" ? `b/${normalizedPath}` : "/dev/null"; + const bodyLines = buildSyntheticPatchBodyLines(lines, action); + if (bodyLines.length === 0) return null; + const oldCount = action === "created" ? 0 : bodyLines.length; + const newCount = action === "created" ? bodyLines.length : 0; + const body = bodyLines.join("\n"); + return `diff --git a/${normalizedPath} b/${normalizedPath}\n--- ${fromPath}\n+++ ${toPath}\n@@ -1,${oldCount} +1,${newCount} @@\n${body}\n`; +} + +function toSyntheticUpdatePatch(change: TimelineFileChange): string | null { + const bodyLines = getPatchBodyLines(change.diff); + if (bodyLines.length === 0) { + return null; + } + const hasUnifiedLines = bodyLines.some( + (line) => line.startsWith("+") || line.startsWith("-"), + ); + if (!hasUnifiedLines) { + return null; + } + + const normalizedPath = normalizePatchPath(change.movePath ?? change.path); + const removedCount = bodyLines.filter((line) => line.startsWith("-")).length; + const addedCount = bodyLines.filter((line) => line.startsWith("+")).length; + return `diff --git a/${normalizedPath} b/${normalizedPath}\n--- a/${normalizedPath}\n+++ b/${normalizedPath}\n@@ -1,${Math.max(removedCount, 1)} +1,${Math.max(addedCount, 1)} @@\n${bodyLines.join("\n")}\n`; +} + +export function getRenderablePatchText( + change: TimelineFileChange, +): RenderablePatchText | null { + const patch = change.diff; + if (patch && patch.trim().length > 0) { + const trimmedPatch = patch.trimEnd(); + if ( + trimmedPatch.startsWith("diff --git") || + (trimmedPatch.includes("--- ") && + trimmedPatch.includes("+++ ") && + trimmedPatch.includes("@@")) + ) { + return { + patch, + disableLineNumbers: false, + }; + } + if (patch.includes("@@")) { + const normalizedPath = normalizePatchPath(change.movePath ?? change.path); + return { + // The leading `diff --git` line is what flips parsePatchFiles into + // git-aware mode — without it, the parser keeps the `a/` and `b/` + // prefixes on the file headers and the card thinks the file was + // renamed (prevName="a/foo", name="b/foo"). + patch: `diff --git a/${normalizedPath} b/${normalizedPath}\n--- a/${normalizedPath}\n+++ b/${normalizedPath}\n${patch.trimEnd()}\n`, + disableLineNumbers: false, + }; + } + } + + const action: FileChangeAction = getFileChangeAction(change); + const syntheticPatch = + (action === "created" + ? toSyntheticPatch(change, "created") + : action === "deleted" + ? toSyntheticPatch(change, "deleted") + : null) ?? toSyntheticUpdatePatch(change); + if (!syntheticPatch) { + return null; + } + return { + patch: syntheticPatch, + disableLineNumbers: true, + }; +} + +export function getPlainDiffFallback( + change: TimelineFileChange, + hasRenderablePatch: boolean, +): string | null { + if (hasRenderablePatch) { + return null; + } + const diff = change.diff?.trimEnd(); + return diff && diff.length > 0 ? diff : null; +} diff --git a/packages/client-core/src/file-preview.ts b/packages/client-core/src/file-preview.ts new file mode 100644 index 0000000000..fd26fdd6f1 --- /dev/null +++ b/packages/client-core/src/file-preview.ts @@ -0,0 +1,286 @@ +const DEFAULT_FILE_PREVIEW_MIME_TYPE = "application/octet-stream"; +const textDecoder = new TextDecoder(); +const strictUtf8TextDecoder = new TextDecoder("utf-8", { fatal: true }); + +const UTF8_TEXT_MIME_TYPES = new Set([ + "application/ecmascript", + "application/javascript", + "application/json", + "application/ld+json", + "application/sql", + "application/toml", + "application/typescript", + "application/x-httpd-php", + "application/x-sh", + "application/x-typescript", + "application/xml", + "application/x-yaml", + "application/yaml", +]); + +const MARKDOWN_FILE_EXTENSIONS = [".md", ".markdown"]; +const MARKDOWN_MIME_TYPES = new Set(["text/markdown", "text/x-markdown"]); +const CSV_FILE_EXTENSIONS = [".csv"]; +const CSV_MIME_TYPES = new Set(["application/csv", "text/csv"]); +const HTML_FILE_EXTENSION = ".html"; +const NULL_CHARACTER = "\u0000"; + +export interface FilePreviewTarget { + name?: string; + path: string; + url: string; +} + +interface FilePreviewBase extends FilePreviewTarget { + kind: "image" | "text" | "unsupported" | "video"; + mimeType: string; +} + +export interface ImageFilePreview extends FilePreviewBase { + kind: "image"; +} + +export interface VideoFilePreview extends FilePreviewBase { + kind: "video"; +} + +export interface TextFilePreview extends FilePreviewBase { + kind: "text"; + content: string; +} + +export interface UnsupportedFilePreview extends FilePreviewBase { + kind: "unsupported"; +} + +export type FilePreview = + | ImageFilePreview + | VideoFilePreview + | TextFilePreview + | UnsupportedFilePreview; + +export type EnvironmentFilePreviewSource = + | { kind: "working-tree" } + | { kind: "head" } + | { kind: "merge-base"; ref: string }; + +export type WorkspaceFilePreviewStatusLabel = "deleted"; + +export interface FilePreviewLineRange { + endLineNumber: number; + startLineNumber: number; +} + +export interface CreateFilePreviewLineRangeArgs { + endLineNumber: number; + startLineNumber: number; +} + +export interface AreFilePreviewLineRangesEqualArgs { + a: FilePreviewLineRange | null; + b: FilePreviewLineRange | null; +} + +export interface GetFilePreviewLineRangeStartArgs { + lineRange: FilePreviewLineRange | null; +} + +export interface WorkspaceFileTabState { + lineRange: FilePreviewLineRange | null; + path: string; + source: EnvironmentFilePreviewSource; + statusLabel: WorkspaceFilePreviewStatusLabel | null; +} + +export interface HostFileTabState { + lineRange: FilePreviewLineRange | null; + path: string; +} + +export interface ThreadStorageFileTabState { + lineRange: FilePreviewLineRange | null; + path: string; +} + +export function createFilePreviewLineRange({ + endLineNumber, + startLineNumber, +}: CreateFilePreviewLineRangeArgs): FilePreviewLineRange | null { + if ( + !Number.isSafeInteger(startLineNumber) || + !Number.isSafeInteger(endLineNumber) || + startLineNumber <= 0 || + endLineNumber <= 0 || + startLineNumber > endLineNumber + ) { + return null; + } + + return { + endLineNumber, + startLineNumber, + }; +} + +export function areFilePreviewLineRangesEqual({ + a, + b, +}: AreFilePreviewLineRangesEqualArgs): boolean { + if (a === null || b === null) { + return a === b; + } + return ( + a.startLineNumber === b.startLineNumber && + a.endLineNumber === b.endLineNumber + ); +} + +export function getFilePreviewLineRangeStart({ + lineRange, +}: GetFilePreviewLineRangeStartArgs): number | null { + return lineRange?.startLineNumber ?? null; +} + +export function areEnvironmentFilePreviewSourcesEqual( + a: EnvironmentFilePreviewSource, + b: EnvironmentFilePreviewSource, +): boolean { + if (a.kind !== b.kind) { + return false; + } + + switch (a.kind) { + case "working-tree": + case "head": + return true; + case "merge-base": + return b.kind === "merge-base" && a.ref === b.ref; + default: { + const exhaustive: never = a; + return exhaustive; + } + } +} + +export interface BuildFilePreviewArgs extends FilePreviewTarget { + contentBytes: Uint8Array; + mimeType: string; +} + +function isKnownTextMimeType(mimeType: string): boolean { + return ( + mimeType.startsWith("text/") || + mimeType.endsWith("+json") || + mimeType.endsWith("+xml") || + UTF8_TEXT_MIME_TYPES.has(mimeType) + ); +} + +function decodeUtf8Text(contentBytes: Uint8Array): string | null { + try { + const content = strictUtf8TextDecoder.decode(contentBytes); + return content.includes(NULL_CHARACTER) ? null : content; + } catch { + return null; + } +} + +function decodeDeclaredTextContent(contentBytes: Uint8Array): string | null { + const content = textDecoder.decode(contentBytes); + return content.includes(NULL_CHARACTER) ? null : content; +} + +function hasMarkdownExtension(path: string): boolean { + const normalizedPath = path.toLowerCase(); + return MARKDOWN_FILE_EXTENSIONS.some((extension) => + normalizedPath.endsWith(extension), + ); +} + +function hasCsvExtension(path: string): boolean { + const normalizedPath = path.toLowerCase(); + return CSV_FILE_EXTENSIONS.some((extension) => + normalizedPath.endsWith(extension), + ); +} + +export function isHtmlFilePreviewPath(path: string): boolean { + return path.toLowerCase().endsWith(HTML_FILE_EXTENSION); +} + +export function normalizeFilePreviewMimeType(value: string | null): string { + const normalizedValue = value?.split(";")[0]?.trim().toLowerCase(); + return normalizedValue && normalizedValue.length > 0 + ? normalizedValue + : DEFAULT_FILE_PREVIEW_MIME_TYPE; +} + +export function isMarkdownFilePreview(preview: FilePreview): boolean { + return ( + preview.kind === "text" && + (MARKDOWN_MIME_TYPES.has(preview.mimeType) || + hasMarkdownExtension(preview.path) || + (preview.name ? hasMarkdownExtension(preview.name) : false)) + ); +} + +export function isCsvFilePreview(preview: FilePreview): boolean { + return ( + preview.kind === "text" && + (CSV_MIME_TYPES.has(preview.mimeType) || + hasCsvExtension(preview.path) || + (preview.name ? hasCsvExtension(preview.name) : false)) + ); +} + +export function buildFilePreview(args: BuildFilePreviewArgs): FilePreview { + const base = { + mimeType: args.mimeType, + name: args.name, + path: args.path, + url: args.url, + }; + + if (args.mimeType.startsWith("image/")) { + return { + kind: "image", + ...base, + }; + } + + if (isKnownTextMimeType(args.mimeType)) { + const textContent = decodeDeclaredTextContent(args.contentBytes); + if (textContent === null) { + return { + kind: "unsupported", + ...base, + }; + } + return { + kind: "text", + ...base, + content: textContent, + }; + } + + const fallbackTextContent = decodeUtf8Text(args.contentBytes); + if (fallbackTextContent !== null) { + return { + kind: "text", + ...base, + content: fallbackTextContent, + }; + } + + if (args.mimeType.startsWith("video/")) { + return { + kind: "video", + ...base, + }; + } + + return { + kind: "unsupported", + ...base, + }; +} diff --git a/packages/client-core/src/index.ts b/packages/client-core/src/index.ts new file mode 100644 index 0000000000..a57b6fc07b --- /dev/null +++ b/packages/client-core/src/index.ts @@ -0,0 +1,65 @@ +// @bb/client-core: DOM-free, React-free client logic shared by the web app +// (`apps/app`) and the native app (`apps/mobile`). Nothing here may touch +// `window`, `document`, `localStorage`, `navigator`, or react-router; see +// `test/no-dom.test.ts`. + +// Cross-surface request types. +export * from "./api-types.js"; + +// Thread state. +export * from "./thread/thread-read-state.js"; +export * from "./thread/thread-activity.js"; + +// Sidebar grouping, sorting, and ordering. +export * from "./codepoint-compare.js"; +export * from "./sidebar/sectionKeys.js"; +export * from "./sidebar/projectThreadGroups.js"; +export * from "./sidebar/machineThreadGroups.js"; +export * from "./sidebar/pinnedSidebarThreads.js"; +export * from "./sidebar/threadReadState.js"; +export * from "./sidebar/sidebarSectionId.js"; +export * from "./sidebar/sidebarSectionOrder.js"; +export * from "./sidebar/neighbor-reorder.js"; + +// Composer: drafts, submission policy, mentions, fork/handoff seeds. +export * from "./prompt/create-resource-prompts.js"; +export * from "./prompt/automation-prompt.js"; +export * from "./prompt/prompt-draft.js"; +export * from "./prompt/follow-up-submit-mode.js"; +export * from "./prompt/threadDetailPromptSubmission.js"; +export * from "./prompt/threadQueuedMessages.js"; +export * from "./prompt/effective-prompt-mode.js"; +export * from "./prompt/permission-mode-options.js"; +export * from "./prompt/mentions/plugin-mention-triggers.js"; +export * from "./prompt/mentions/types.js"; +export * from "./prompt/mentions/find-active-trigger.js"; +export * from "./prompt/mentions/command-trigger.js"; +export * from "./prompt/fork-thread-request.js"; +export * from "./prompt/thread-handoff-request.js"; + +// Timeline: row policy and the loaded-window merge. +export * from "./timeline/thread-runtime-status.js"; +export * from "./timeline/timeline-auto-expand.js"; +export * from "./timeline/timelineRowSignatures.js"; +export * from "./timeline/conversation-message-limits.js"; +export * from "./timeline/compute-muted-prefix-length.js"; +export * from "./timeline/conversation-turn-request-label.js"; +export * from "./timeline/optimistic-timeline-row.js"; +export * from "./timeline/timeline-merge.js"; + +// Diff patch normalization (parser-agnostic). +export * from "./diff/renderable-patch.js"; + +// Terminal attach transport. +export * from "./terminal/terminal-websocket-transport.js"; +export * from "./terminal/terminal-websocket-path.js"; + +// Secondary panel tab state. +export * from "./file-preview.js"; +export * from "./panel/fixed-panel-tabs-state.js"; +export * from "./panel/secondaryPanelTabState.js"; +export * from "./panel/array-move.js"; + +// Links and routes. +export * from "./localhost-link-rewrite.js"; +export * from "./routes/route-paths.js"; diff --git a/packages/client-core/src/localhost-link-rewrite.ts b/packages/client-core/src/localhost-link-rewrite.ts new file mode 100644 index 0000000000..659a071f07 --- /dev/null +++ b/packages/client-core/src/localhost-link-rewrite.ts @@ -0,0 +1,47 @@ +export const REWRITE_LOCALHOST_LINKS_STORAGE_KEY = "bb.rewriteLocalhostLinks"; + +export const REWRITE_LOCALHOST_LINKS_DEFAULT = true; + +interface RewriteLocalhostLinkHrefArgs { + currentHostname: string | undefined; + enabled: boolean; + href: string | undefined; +} + +const LOOPBACK_LINK_HOSTNAMES = new Set(["127.0.0.1", "localhost"]); + +function isRewriteableLoopbackLink(url: URL): boolean { + return ( + (url.protocol === "http:" || url.protocol === "https:") && + LOOPBACK_LINK_HOSTNAMES.has(url.hostname.toLowerCase()) + ); +} + +/** + * Rewrites loopback links an agent emitted (`http://localhost:5173`) to the + * host the client reached the bb server on, so links open on the machine that + * is actually serving them when bb is used remotely. + */ +export function rewriteLocalhostLinkHref({ + currentHostname, + enabled, + href, +}: RewriteLocalhostLinkHrefArgs): string | undefined { + if (!enabled || href === undefined || currentHostname === undefined) { + return href; + } + + let url: URL; + try { + url = new URL(href); + } catch { + return href; + } + + if (!isRewriteableLoopbackLink(url)) { + return href; + } + + url.hostname = currentHostname; + return url.toString(); +} diff --git a/packages/client-core/src/panel/array-move.ts b/packages/client-core/src/panel/array-move.ts new file mode 100644 index 0000000000..e52f9338b5 --- /dev/null +++ b/packages/client-core/src/panel/array-move.ts @@ -0,0 +1,19 @@ +/** + * Returns a copy of `array` with the item at `from` moved to `to`. Mirrors + * `arrayMove` from `@dnd-kit/sortable`, which the web sidebar drag layer uses; + * kept local so the shared tab-state reducers do not depend on a DOM drag + * library. + */ +export function arrayMove( + array: readonly T[], + from: number, + to: number, +): T[] { + const result = [...array]; + const [moved] = result.splice(from, 1); + if (moved === undefined) { + return result; + } + result.splice(to < 0 ? result.length + to : to, 0, moved); + return result; +} diff --git a/packages/client-core/src/panel/fixed-panel-tabs-state.ts b/packages/client-core/src/panel/fixed-panel-tabs-state.ts new file mode 100644 index 0000000000..260cc7535e --- /dev/null +++ b/packages/client-core/src/panel/fixed-panel-tabs-state.ts @@ -0,0 +1,1156 @@ +import { z } from "zod"; +import { + BB_DESKTOP_BROWSER_MAX_TITLE_LENGTH, + BB_DESKTOP_BROWSER_MAX_URL_LENGTH, +} from "@bb/desktop-contract"; +import { + terminalCreateTargetSchema, + threadTabFileOpenerOwnerSchema, + type TerminalCreateTarget, + type ThreadTabFileOpenerOwner, +} from "@bb/server-contract"; +import { + areFilePreviewLineRangesEqual, + areEnvironmentFilePreviewSourcesEqual, + type EnvironmentFilePreviewSource, + type FilePreviewLineRange, + type HostFileTabState, + type ThreadStorageFileTabState, + type WorkspaceFilePreviewStatusLabel, + type WorkspaceFileTabState, +} from "../file-preview.js"; + +export const FIXED_PANEL_TABS_STATE_STORAGE_PREFIX = + "bb.thread.fixedPanelTabsState"; +export const FIXED_PANEL_TABS_STATE_STORAGE_VERSION = 1; +export const FIXED_PANEL_TABS_IDLE_EXPIRY_MS = 14 * 24 * 60 * 60 * 1000; + +const SECONDARY_PANEL_TAB_ID_ENVIRONMENT_NONE = "none"; +const THREAD_INFO_TAB_ID = "thread-info:thread-info:none"; +const GIT_DIFF_TAB_ID = "git-diff:git-diff:none"; +const NEW_TAB_TAB_ID = "new-tab:new-tab:none"; + +const environmentFilePreviewSourceSchema: z.ZodType = + z.discriminatedUnion("kind", [ + z + .object({ + kind: z.literal("working-tree"), + }) + .strict(), + z + .object({ + kind: z.literal("head"), + }) + .strict(), + z + .object({ + kind: z.literal("merge-base"), + ref: z.string().min(1), + }) + .strict(), + ]); +const workspaceFilePreviewStatusLabelSchema: z.ZodType = + z.literal("deleted").nullable(); +const filePreviewLineRangeSchema: z.ZodType = z + .object({ + endLineNumber: z.number().int().positive(), + startLineNumber: z.number().int().positive(), + }) + .strict() + .refine((range) => range.startLineNumber <= range.endLineNumber); +const threadInfoFixedPanelTabSchema = z + .object({ + id: z.string().min(1), + kind: z.literal("thread-info"), + }) + .strict(); +const gitDiffFixedPanelTabSchema = z + .object({ + id: z.string().min(1), + kind: z.literal("git-diff"), + }) + .strict(); +const pluginPageFixedPanelTabSchema = z + .object({ + fixedTabId: z.string().min(1), + id: z.string().min(1), + kind: z.literal("plugin-page-fixed"), + pageId: z.string().min(1), + pluginId: z.string().min(1), + }) + .strict(); +const workspaceFilePreviewFixedPanelTabSchema = z + .object({ + environmentId: z.string().min(1).nullable(), + id: z.string().min(1), + kind: z.literal("workspace-file-preview"), + lineRange: filePreviewLineRangeSchema.nullable().default(null), + path: z.string().min(1), + projectId: z.string().min(1).nullable().default(null), + source: environmentFilePreviewSourceSchema, + statusLabel: workspaceFilePreviewStatusLabelSchema, + }) + .strict(); +const hostFilePreviewFixedPanelTabSchema = z + .object({ + environmentId: z.string().min(1).nullable().default(null), + id: z.string().min(1), + kind: z.literal("host-file-preview"), + lineRange: filePreviewLineRangeSchema.nullable().default(null), + path: z.string().min(1), + threadId: z.string().min(1).nullable().default(null), + }) + .strict(); +const threadStorageFilePreviewFixedPanelTabSchema = z + .object({ + environmentId: z.string().min(1).nullable().default(null), + id: z.string().min(1), + isPinned: z.boolean(), + kind: z.literal("thread-storage-file-preview"), + lineRange: filePreviewLineRangeSchema.nullable().default(null), + path: z.string().min(1), + threadId: z.string().min(1).nullable().default(null), + }) + .strict(); +const browserFixedPanelTabSchema = z + .object({ + environmentId: z.string().min(1).nullable().default(null), + id: z.string().min(1), + kind: z.literal("browser"), + title: z + .string() + .min(1) + .max(BB_DESKTOP_BROWSER_MAX_TITLE_LENGTH) + .nullable(), + url: z.string().max(BB_DESKTOP_BROWSER_MAX_URL_LENGTH), + }) + .strict(); +const newTabFixedPanelTabSchema = z + .object({ + id: z.string().min(1), + kind: z.literal("new-tab"), + }) + .strict(); +const terminalFixedPanelTabSchema = z + .object({ + id: z.string().min(1), + kind: z.literal("terminal"), + terminalId: z.string().min(1), + // Nav-panel right panels can host terminals from an explicit target. The + // field is absent on thread/root-compose tabs, whose surface owns it. + target: terminalCreateTargetSchema.optional(), + }) + .strict(); +const pluginPanelFixedPanelTabSchema = z + .object({ + actionId: z.string().min(1), + fileOpenerOwner: threadTabFileOpenerOwnerSchema.optional(), + id: z.string().min(1), + kind: z.literal("plugin-panel"), + paramsJson: z.string().nullable(), + pluginId: z.string().min(1), + title: z.string().min(1), + }) + .strict(); +const secondaryFixedPanelTabSchema = z.union([ + threadInfoFixedPanelTabSchema, + gitDiffFixedPanelTabSchema, + pluginPageFixedPanelTabSchema, + pluginPanelFixedPanelTabSchema, + workspaceFilePreviewFixedPanelTabSchema, + hostFilePreviewFixedPanelTabSchema, + threadStorageFilePreviewFixedPanelTabSchema, + browserFixedPanelTabSchema, + newTabFixedPanelTabSchema, + terminalFixedPanelTabSchema, +]); +/** + * The native side chat is gone, but persisted panel state can still hold its + * tabs. Drop them at the parse boundary so old state loads and the removed + * tabs simply disappear from the strip. + */ +const secondaryFixedPanelTabsSchema = z.preprocess( + (value) => + Array.isArray(value) + ? value.filter( + (tab) => + !( + typeof tab === "object" && + tab !== null && + (tab as { kind?: unknown }).kind === "side-chat" + ), + ) + : value, + z.array(secondaryFixedPanelTabSchema), +); +const secondaryFixedPanelTabGroupStateSchema = z + .object({ + tabs: secondaryFixedPanelTabsSchema, + activeTabId: z.string().min(1).nullable(), + isOpen: z.boolean(), + }) + .strict(); +const fixedPanelTabsStateSchema = z + .object({ + version: z.literal(FIXED_PANEL_TABS_STATE_STORAGE_VERSION), + secondary: secondaryFixedPanelTabGroupStateSchema, + lastUsedAt: z.number().int().nonnegative(), + }) + .passthrough(); + +export interface ThreadInfoFixedPanelTab { + id: string; + kind: "thread-info"; +} + +export interface GitDiffFixedPanelTab { + id: string; + kind: "git-diff"; +} + +export interface PluginPageFixedPanelTab { + fixedTabId: string; + id: string; + kind: "plugin-page-fixed"; + pageId: string; + pluginId: string; +} + +export type FixedPanelViewTab = + | ThreadInfoFixedPanelTab + | GitDiffFixedPanelTab + | PluginPageFixedPanelTab; + +/** + * A panel tab opened by a plugin `threadPanelAction` (plugin design §5.2) — + * a closable file-strip tab like a terminal, not a fixed view. + * `paramsJson` is the JSON-serialized `openPanel` params (null = none); it + * is part of the tab identity, so the same action can hold several tabs + * with different params while identical re-opens focus the existing one. + * If the plugin/action is gone on restore the content degrades to a + * placeholder. + */ +export interface PluginPanelFixedPanelTab { + actionId: string; + /** Present only when this plugin panel diverted a native file preview. */ + fileOpenerOwner?: ThreadTabFileOpenerOwner; + id: string; + kind: "plugin-panel"; + paramsJson: string | null; + pluginId: string; + title: string; +} + +export interface WorkspaceFilePreviewFixedPanelTab { + environmentId: string | null; + id: string; + kind: "workspace-file-preview"; + lineRange: FilePreviewLineRange | null; + path: string; + projectId: string | null; + source: EnvironmentFilePreviewSource; + statusLabel: WorkspaceFilePreviewStatusLabel | null; +} + +export interface HostFilePreviewFixedPanelTab { + environmentId: string | null; + id: string; + kind: "host-file-preview"; + lineRange: FilePreviewLineRange | null; + path: string; + threadId: string | null; +} + +export interface ThreadStorageFilePreviewFixedPanelTab { + environmentId: string | null; + id: string; + isPinned: boolean; + kind: "thread-storage-file-preview"; + lineRange: FilePreviewLineRange | null; + path: string; + threadId: string | null; +} + +/** + * A web browser tab hosted by a native Electron `WebContentsView` (desktop + * only). `url` is the last-loaded page (empty string = the new-tab screen) and + * `title` is the last title pushed from the view, so the tab pill keeps its + * label while inactive and across reloads. Favicons are intentionally not + * persisted/rendered (untrusted remote URL); the pill shows a generic globe. + * Live loading state is not persisted — it is held by the active tab's chrome. + */ +export interface BrowserFixedPanelTab { + environmentId: string | null; + id: string; + kind: "browser"; + title: string | null; + url: string; +} + +export interface NewTabFixedPanelTab { + id: string; + kind: "new-tab"; +} + +export interface TerminalFixedPanelTab { + id: string; + kind: "terminal"; + terminalId: string; + target?: TerminalCreateTarget; +} + +export type SecondaryFixedPanelTab = + | ThreadInfoFixedPanelTab + | GitDiffFixedPanelTab + | PluginPageFixedPanelTab + | PluginPanelFixedPanelTab + | WorkspaceFilePreviewFixedPanelTab + | HostFilePreviewFixedPanelTab + | ThreadStorageFilePreviewFixedPanelTab + | BrowserFixedPanelTab + | NewTabFixedPanelTab + | TerminalFixedPanelTab; + +/** + * The subset of secondary-panel tabs rendered as closable file tabs in the tab + * strip. Excludes thread-info and git-diff, which are fixed views toggled + * separately rather than ordered alongside opened files. + */ +export type SecondaryFileFixedPanelTab = + | WorkspaceFilePreviewFixedPanelTab + | HostFilePreviewFixedPanelTab + | ThreadStorageFilePreviewFixedPanelTab + | BrowserFixedPanelTab + | NewTabFixedPanelTab + | TerminalFixedPanelTab + | PluginPanelFixedPanelTab; + +export type FixedPanelTab = SecondaryFixedPanelTab; + +export interface FixedPanelTabGroupState { + tabs: readonly FixedPanelTab[]; + activeTabId: string | null; +} + +export interface FixedSecondaryPanelTabGroupState extends FixedPanelTabGroupState { + isOpen: boolean; +} + +export interface FixedPanelTabsState { + version: typeof FIXED_PANEL_TABS_STATE_STORAGE_VERSION; + secondary: FixedSecondaryPanelTabGroupState; + lastUsedAt: number; +} + +interface FixedPanelTabsStorageKeyArgs { + threadId: string; +} + +interface CreateFixedPanelTabsStateArgs { + lastUsedAt?: number; + secondary?: FixedSecondaryPanelTabGroupState; +} + +export interface ParseFixedPanelTabsStateArgs { + initialValue: FixedPanelTabsState; + now: number; + storedValue: string | null; +} + +export interface ParseFixedPanelTabsStateForStorageResult { + shouldPrune: boolean; + state: FixedPanelTabsState; +} + +interface SerializeFixedPanelTabsStateArgs { + state: FixedPanelTabsState; +} + +interface IsFixedPanelTabsStateExpiredArgs { + now: number; + state: FixedPanelTabsState; +} + +interface NormalizeFixedPanelTabsStateArgs { + state: FixedPanelTabsState; +} + +interface StripTransientFixedPanelTabsStateForStorageArgs { + state: FixedPanelTabsState; +} + +interface NormalizeFixedPanelTabGroupStateArgs { + group: FixedPanelTabGroupState; +} + +interface CreateThreadStorageFilePreviewFixedPanelTabArgs { + environmentId: string | null; + isPinned: boolean; + tab: ThreadStorageFileTabState; + threadId: string; +} + +interface CreateHostFilePreviewFixedPanelTabArgs { + environmentId: string; + tab: HostFileTabState; + threadId: string; +} + +interface CreateWorkspaceFilePreviewFixedPanelTabArgs { + environmentId: string | null; + projectId: string | null; + tab: WorkspaceFileTabState; +} + +interface CreateTerminalFixedPanelTabArgs { + terminalId: string; + target?: TerminalCreateTarget; +} + +interface CreatePluginPanelFixedPanelTabArgs { + actionId: string; + paramsJson: string | null; + pluginId: string; + title: string; +} + +interface CreatePluginPageFixedPanelTabArgs { + fixedTabId: string; + pageId: string; + pluginId: string; +} + +interface BuildFixedPanelTabIdArgs { + environmentId: string | null; + kind: FixedPanelTab["kind"]; + path: string; +} + +interface BuildWorkspaceFilePreviewTabIdArgs { + environmentId: string | null; + path: string; + projectId: string | null; +} + +interface BuildHostFilePreviewTabIdArgs { + environmentId: string | null; + path: string; + threadId: string | null; +} + +interface BuildThreadStorageFilePreviewTabIdArgs { + path: string; + threadId: string | null; +} + +interface NormalizeFixedPanelTabGroupStateResult { + activeTabId: string | null; + tabs: readonly FixedPanelTab[]; +} + +function normalizeStorageSegment(value: string): string { + return encodeURIComponent(value.trim()); +} + +function decodeStorageSegment(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +export function buildFixedPanelTabId({ + environmentId, + kind, + path, +}: BuildFixedPanelTabIdArgs): string { + return [ + kind, + encodeURIComponent(path), + encodeURIComponent( + environmentId ?? SECONDARY_PANEL_TAB_ID_ENVIRONMENT_NONE, + ), + ].join(":"); +} + +function buildWorkspaceFilePreviewTabId({ + environmentId, + path, + projectId, +}: BuildWorkspaceFilePreviewTabIdArgs): string { + return buildFixedPanelTabId({ + environmentId: environmentId ?? (projectId ? `project:${projectId}` : null), + kind: "workspace-file-preview", + path, + }); +} + +function buildHostFilePreviewTabId({ + environmentId, + path, + threadId, +}: BuildHostFilePreviewTabIdArgs): string { + if (threadId === null || environmentId === null) { + return buildFixedPanelTabId({ + environmentId: null, + kind: "host-file-preview", + path, + }); + } + return buildFixedPanelTabId({ + environmentId: `thread:${threadId}:environment:${environmentId}`, + kind: "host-file-preview", + path, + }); +} + +function buildThreadStorageFilePreviewTabId({ + path, + threadId, +}: BuildThreadStorageFilePreviewTabIdArgs): string { + return buildFixedPanelTabId({ + environmentId: threadId === null ? null : `thread:${threadId}`, + kind: "thread-storage-file-preview", + path, + }); +} + +export function createThreadInfoFixedPanelTab(): ThreadInfoFixedPanelTab { + return { + id: THREAD_INFO_TAB_ID, + kind: "thread-info", + }; +} + +export function createGitDiffFixedPanelTab(): GitDiffFixedPanelTab { + return { + id: GIT_DIFF_TAB_ID, + kind: "git-diff", + }; +} + +export function createPluginPageFixedPanelTab({ + fixedTabId, + pageId, + pluginId, +}: CreatePluginPageFixedPanelTabArgs): PluginPageFixedPanelTab { + return { + fixedTabId, + id: buildFixedPanelTabId({ + environmentId: null, + kind: "plugin-page-fixed", + path: `${pluginId}:${pageId}:${fixedTabId}`, + }), + kind: "plugin-page-fixed", + pageId, + pluginId, + }; +} + +export function createPluginPanelFixedPanelTab({ + actionId, + paramsJson, + pluginId, + title, +}: CreatePluginPanelFixedPanelTabArgs): PluginPanelFixedPanelTab { + return { + actionId, + // Params are part of the identity (title is not): re-opening the same + // action with the same params focuses the existing tab, different + // params open a sibling tab. + id: buildFixedPanelTabId({ + environmentId: null, + kind: "plugin-panel", + path: `${pluginId}:${actionId}:${paramsJson ?? ""}`, + }), + kind: "plugin-panel", + paramsJson, + pluginId, + title, + }; +} + +export function createWorkspaceFilePreviewFixedPanelTab({ + environmentId, + projectId, + tab, +}: CreateWorkspaceFilePreviewFixedPanelTabArgs): WorkspaceFilePreviewFixedPanelTab { + return { + environmentId, + id: buildWorkspaceFilePreviewTabId({ + environmentId, + path: tab.path, + projectId, + }), + kind: "workspace-file-preview", + lineRange: tab.lineRange, + path: tab.path, + projectId, + source: tab.source, + statusLabel: tab.statusLabel, + }; +} + +export function createHostFilePreviewFixedPanelTab({ + environmentId, + tab, + threadId, +}: CreateHostFilePreviewFixedPanelTabArgs): HostFilePreviewFixedPanelTab { + return { + environmentId, + id: buildHostFilePreviewTabId({ + environmentId, + path: tab.path, + threadId, + }), + kind: "host-file-preview", + lineRange: tab.lineRange, + path: tab.path, + threadId, + }; +} + +export function createThreadStorageFilePreviewFixedPanelTab({ + environmentId, + isPinned, + tab, + threadId, +}: CreateThreadStorageFilePreviewFixedPanelTabArgs): ThreadStorageFilePreviewFixedPanelTab { + return { + environmentId, + id: buildThreadStorageFilePreviewTabId({ + path: tab.path, + threadId, + }), + isPinned, + kind: "thread-storage-file-preview", + lineRange: tab.lineRange, + path: tab.path, + threadId, + }; +} + +export function createNewTabFixedPanelTab(): NewTabFixedPanelTab { + return { + id: NEW_TAB_TAB_ID, + kind: "new-tab", + }; +} + +/** + * Runtime invariant shared by every fixed secondary-panel host: an open panel + * always has an active tab. Keep a persisted active tab when it still exists, + * fall back to the first surviving tab when it does not, and close the panel + * when hydration leaves no tabs to show. + */ +export function ensureOpenFixedPanelHasActiveTab( + state: FixedPanelTabsState, +): FixedPanelTabsState { + if (!state.secondary.isOpen) { + return state; + } + + const activeTab = state.secondary.tabs.find( + (tab) => tab.id === state.secondary.activeTabId, + ); + if (activeTab !== undefined) { + return state; + } + + const fallbackTab = state.secondary.tabs[0]; + if (fallbackTab === undefined) { + return { + ...state, + secondary: { + ...state.secondary, + activeTabId: null, + isOpen: false, + }, + }; + } + + return { + ...state, + secondary: { + ...state.secondary, + activeTabId: fallbackTab.id, + }, + }; +} + +export function createTerminalFixedPanelTab({ + terminalId, + target, +}: CreateTerminalFixedPanelTabArgs): TerminalFixedPanelTab { + return { + id: buildFixedPanelTabId({ + environmentId: null, + kind: "terminal", + path: terminalId, + }), + kind: "terminal", + terminalId, + ...(target !== undefined ? { target } : {}), + }; +} + +function normalizeFixedPanelTabId(tab: FixedPanelTab): FixedPanelTab { + switch (tab.kind) { + case "thread-info": + return tab.id === THREAD_INFO_TAB_ID + ? tab + : { + ...tab, + id: THREAD_INFO_TAB_ID, + }; + case "git-diff": + return tab.id === GIT_DIFF_TAB_ID + ? tab + : { + ...tab, + id: GIT_DIFF_TAB_ID, + }; + case "plugin-page-fixed": { + const id = createPluginPageFixedPanelTab({ + fixedTabId: tab.fixedTabId, + pageId: tab.pageId, + pluginId: tab.pluginId, + }).id; + return tab.id === id ? tab : { ...tab, id }; + } + case "workspace-file-preview": { + const id = buildWorkspaceFilePreviewTabId({ + environmentId: tab.environmentId, + path: tab.path, + projectId: tab.projectId, + }); + return tab.id === id ? tab : { ...tab, id }; + } + case "host-file-preview": { + const id = buildHostFilePreviewTabId({ + environmentId: tab.environmentId, + path: tab.path, + threadId: tab.threadId, + }); + return tab.id === id ? tab : { ...tab, id }; + } + case "thread-storage-file-preview": { + const id = buildThreadStorageFilePreviewTabId({ + path: tab.path, + threadId: tab.threadId, + }); + return tab.id === id ? tab : { ...tab, id }; + } + case "browser": { + const idSegments = tab.id.split(":"); + const browserPath = + idSegments.length === 3 && idSegments[0] === "browser" + ? decodeStorageSegment(idSegments[1] ?? "") + : tab.id; + const id = buildFixedPanelTabId({ + environmentId: tab.environmentId, + kind: tab.kind, + path: browserPath, + }); + return tab.id === id ? tab : { ...tab, id }; + } + case "new-tab": + return tab.id === NEW_TAB_TAB_ID + ? tab + : { + ...tab, + id: NEW_TAB_TAB_ID, + }; + case "plugin-panel": { + const id = createPluginPanelFixedPanelTab({ + actionId: tab.actionId, + paramsJson: tab.paramsJson, + pluginId: tab.pluginId, + title: tab.title, + }).id; + return tab.id === id ? tab : { ...tab, id }; + } + case "terminal": { + const id = buildFixedPanelTabId({ + environmentId: null, + kind: tab.kind, + path: tab.terminalId, + }); + return tab.id === id ? tab : { ...tab, id }; + } + } +} + +function isTransientFixedPanelTab(tab: FixedPanelTab): boolean { + return tab.kind === "new-tab"; +} + +function normalizeFixedPanelTabGroupState({ + group, +}: NormalizeFixedPanelTabGroupStateArgs): NormalizeFixedPanelTabGroupStateResult { + const seenTabIds = new Set(); + const tabs: FixedPanelTab[] = []; + let activeTabId: string | null = null; + for (const tab of group.tabs) { + const normalizedTab = normalizeFixedPanelTabId(tab); + if ( + isTransientFixedPanelTab(normalizedTab) || + seenTabIds.has(normalizedTab.id) + ) { + continue; + } + seenTabIds.add(normalizedTab.id); + tabs.push(normalizedTab); + if ( + group.activeTabId !== null && + (tab.id === group.activeTabId || normalizedTab.id === group.activeTabId) + ) { + activeTabId = normalizedTab.id; + } + } + + return { + tabs, + activeTabId, + }; +} + +function normalizeFixedSecondaryPanelTabGroupState( + group: FixedSecondaryPanelTabGroupState, +): FixedSecondaryPanelTabGroupState { + return { + ...normalizeFixedPanelTabGroupState({ + group, + }), + isOpen: group.isOpen, + }; +} + +function stripTransientFixedPanelTabForStorage( + tab: FixedPanelTab, +): FixedPanelTab { + switch (tab.kind) { + case "workspace-file-preview": + case "host-file-preview": + case "thread-storage-file-preview": + return { + ...tab, + lineRange: null, + }; + case "thread-info": + case "git-diff": + case "plugin-page-fixed": + case "browser": + case "new-tab": + case "terminal": + return tab; + case "plugin-panel": + return tab.fileOpenerOwner === undefined + ? tab + : { + ...tab, + fileOpenerOwner: stripFileOpenerOwnerForStorage( + tab.fileOpenerOwner, + ), + }; + } +} + +function stripFileOpenerOwnerForStorage( + owner: ThreadTabFileOpenerOwner, +): ThreadTabFileOpenerOwner { + switch (owner.kind) { + case "workspace-file-preview": + return { ...owner, tab: { ...owner.tab, lineRange: null } }; + case "host-file-preview": + return { ...owner, tab: { ...owner.tab, lineRange: null } }; + case "thread-storage-file-preview": + return { ...owner, tab: { ...owner.tab, lineRange: null } }; + } +} + +function stripTransientFixedPanelTabsStateForStorage({ + state, +}: StripTransientFixedPanelTabsStateForStorageArgs): FixedPanelTabsState { + return { + ...state, + secondary: { + ...state.secondary, + tabs: state.secondary.tabs.map(stripTransientFixedPanelTabForStorage), + }, + }; +} + +export function normalizeFixedPanelTabsState({ + state, +}: NormalizeFixedPanelTabsStateArgs): FixedPanelTabsState { + const normalizedSecondary = normalizeFixedSecondaryPanelTabGroupState( + state.secondary, + ); + + return { + version: state.version, + secondary: normalizedSecondary, + lastUsedAt: state.lastUsedAt, + }; +} + +export function createEmptyFixedPanelTabsState( + args: CreateFixedPanelTabsStateArgs = {}, +): FixedPanelTabsState { + return normalizeFixedPanelTabsState({ + state: { + version: FIXED_PANEL_TABS_STATE_STORAGE_VERSION, + secondary: args.secondary ?? { + tabs: [], + activeTabId: null, + isOpen: false, + }, + lastUsedAt: args.lastUsedAt ?? 0, + }, + }); +} + +export const EMPTY_FIXED_PANEL_TABS_STATE = createEmptyFixedPanelTabsState(); + +export function getFixedPanelTabsStateStorageKey({ + threadId, +}: FixedPanelTabsStorageKeyArgs): string { + return `${FIXED_PANEL_TABS_STATE_STORAGE_PREFIX}-${normalizeStorageSegment( + threadId, + )}-${FIXED_PANEL_TABS_STATE_STORAGE_VERSION}`; +} + +export function isFixedPanelTabsStateStorageKey(key: string): boolean { + return key.startsWith(`${FIXED_PANEL_TABS_STATE_STORAGE_PREFIX}-`); +} + +export function isFixedPanelTabsStateExpired({ + now, + state, +}: IsFixedPanelTabsStateExpiredArgs): boolean { + return now - state.lastUsedAt > FIXED_PANEL_TABS_IDLE_EXPIRY_MS; +} + +export function parseFixedPanelTabsState({ + initialValue, + now, + storedValue, +}: ParseFixedPanelTabsStateArgs): FixedPanelTabsState { + return parseFixedPanelTabsStateForStorage({ + initialValue, + now, + storedValue, + }).state; +} + +/** + * Parses persisted panel state and says whether the stored entry is worth + * keeping. Storage owners (web localStorage pruning) call this directly; + * everything else goes through {@link parseFixedPanelTabsState}. + */ +export function parseFixedPanelTabsStateForStorage({ + initialValue, + now, + storedValue, +}: ParseFixedPanelTabsStateArgs): ParseFixedPanelTabsStateForStorageResult { + if (storedValue === null) { + return { + shouldPrune: false, + state: initialValue, + }; + } + + let parsedValue: unknown; + try { + parsedValue = JSON.parse(storedValue); + } catch { + return { + shouldPrune: true, + state: initialValue, + }; + } + + const stateResult = fixedPanelTabsStateSchema.safeParse(parsedValue); + if (!stateResult.success) { + return { + shouldPrune: true, + state: initialValue, + }; + } + + const normalizedState = stripTransientFixedPanelTabsStateForStorage({ + state: normalizeFixedPanelTabsState({ + state: stateResult.data, + }), + }); + if (isFixedPanelTabsStateExpired({ now, state: normalizedState })) { + return { + shouldPrune: true, + state: initialValue, + }; + } + + return { + shouldPrune: false, + state: ensureOpenFixedPanelHasActiveTab(normalizedState), + }; +} + +/** + * Prune decision for one stored blob. The idle-expiry check reads only + * `lastUsedAt` from the parsed JSON, so an expired blob (the common case in a + * long-lived browser profile) is dropped without a full schema parse; only + * blobs that are still fresh, or that carry no usable timestamp, go through + * the schema. + */ +export function shouldPruneStoredFixedPanelTabsState( + storedValue: string | null, + now: number, +): boolean { + if (storedValue === null) { + return false; + } + let parsedValue: unknown; + try { + parsedValue = JSON.parse(storedValue); + } catch { + return true; + } + if (typeof parsedValue !== "object" || parsedValue === null) { + return true; + } + const lastUsedAt = Reflect.get(parsedValue, "lastUsedAt"); + if ( + typeof lastUsedAt === "number" && + Number.isInteger(lastUsedAt) && + lastUsedAt >= 0 + ) { + if (now - lastUsedAt > FIXED_PANEL_TABS_IDLE_EXPIRY_MS) { + return true; + } + return !fixedPanelTabsStateSchema.safeParse(parsedValue).success; + } + return parseFixedPanelTabsStateForStorage({ + initialValue: EMPTY_FIXED_PANEL_TABS_STATE, + now, + storedValue, + }).shouldPrune; +} + +export function serializeFixedPanelTabsState({ + state, +}: SerializeFixedPanelTabsStateArgs): string { + return JSON.stringify( + stripTransientFixedPanelTabsStateForStorage({ + state: normalizeFixedPanelTabsState({ state }), + }), + ); +} + +export function areFixedPanelTabsEquivalent( + a: FixedPanelTab, + b: FixedPanelTab, +): boolean { + if (a.id !== b.id || a.kind !== b.kind) { + return false; + } + switch (a.kind) { + case "thread-info": + case "git-diff": + case "new-tab": + return true; + case "plugin-page-fixed": + return ( + b.kind === "plugin-page-fixed" && + a.pluginId === b.pluginId && + a.pageId === b.pageId && + a.fixedTabId === b.fixedTabId + ); + case "plugin-panel": + return ( + b.kind === "plugin-panel" && + a.pluginId === b.pluginId && + a.actionId === b.actionId && + a.paramsJson === b.paramsJson && + areFileOpenerOwnersEqual(a.fileOpenerOwner, b.fileOpenerOwner) && + a.title === b.title + ); + case "workspace-file-preview": + return ( + b.kind === "workspace-file-preview" && + a.environmentId === b.environmentId && + areFilePreviewLineRangesEqual({ + a: a.lineRange, + b: b.lineRange, + }) && + a.path === b.path && + a.projectId === b.projectId && + areEnvironmentFilePreviewSourcesEqual(a.source, b.source) && + a.statusLabel === b.statusLabel + ); + case "host-file-preview": + return ( + b.kind === "host-file-preview" && + a.environmentId === b.environmentId && + areFilePreviewLineRangesEqual({ + a: a.lineRange, + b: b.lineRange, + }) && + a.path === b.path && + a.threadId === b.threadId + ); + case "browser": + return ( + b.kind === "browser" && + a.environmentId === b.environmentId && + a.url === b.url && + a.title === b.title + ); + case "thread-storage-file-preview": + return ( + b.kind === "thread-storage-file-preview" && + a.environmentId === b.environmentId && + a.isPinned === b.isPinned && + areFilePreviewLineRangesEqual({ + a: a.lineRange, + b: b.lineRange, + }) && + a.path === b.path && + a.threadId === b.threadId + ); + case "terminal": + return ( + b.kind === "terminal" && + a.terminalId === b.terminalId && + JSON.stringify(a.target) === JSON.stringify(b.target) + ); + } +} + +function areFileOpenerOwnersEqual( + a: ThreadTabFileOpenerOwner | undefined, + b: ThreadTabFileOpenerOwner | undefined, +): boolean { + if (a === undefined || b === undefined) return a === b; + if ( + a.kind !== b.kind || + a.environmentId !== b.environmentId || + a.threadId !== b.threadId || + a.tab.path !== b.tab.path || + !areFilePreviewLineRangesEqual({ + a: a.tab.lineRange, + b: b.tab.lineRange, + }) + ) { + return false; + } + if (a.kind !== "workspace-file-preview") return true; + return ( + b.kind === "workspace-file-preview" && + a.projectId === b.projectId && + areEnvironmentFilePreviewSourcesEqual(a.tab.source, b.tab.source) && + a.tab.statusLabel === b.tab.statusLabel + ); +} diff --git a/packages/client-core/src/panel/secondaryPanelTabState.ts b/packages/client-core/src/panel/secondaryPanelTabState.ts new file mode 100644 index 0000000000..43e95dd462 --- /dev/null +++ b/packages/client-core/src/panel/secondaryPanelTabState.ts @@ -0,0 +1,500 @@ +import { + areFixedPanelTabsEquivalent, + type BrowserFixedPanelTab, + type FixedPanelTab, + type FixedPanelTabsState, + type FixedPanelViewTab, + type NewTabFixedPanelTab, + type SecondaryFileFixedPanelTab, + type SecondaryFixedPanelTab, + type ThreadStorageFilePreviewFixedPanelTab, + type WorkspaceFilePreviewFixedPanelTab, +} from "./fixed-panel-tabs-state.js"; +import { arrayMove } from "./array-move.js"; + +interface SetSecondaryPanelTabsInStateArgs { + activeTabId: string | null; + isOpen: boolean; + state: FixedPanelTabsState; + tabs: readonly FixedPanelTab[]; +} + +interface OpenSecondaryPanelTabInStateArgs { + state: FixedPanelTabsState; + tab: FixedPanelTab; +} + +interface ReplaceNewTabWithSecondaryPanelTabInStateArgs { + state: FixedPanelTabsState; + tab: FixedPanelTab; +} + +interface UpdateSecondaryPanelTabInStateArgs { + state: FixedPanelTabsState; + tab: FixedPanelTab; +} + +interface ReorderSecondaryPanelFileTabInStateArgs { + activeTabId: string; + overTabId: string; + state: FixedPanelTabsState; +} + +interface GetActiveTabIdAfterCloseArgs { + activeTabId: string | null; + closedTabId: string; + tabsBeforeClose: readonly FixedPanelTab[]; + tabsAfterClose: readonly FixedPanelTab[]; +} + +interface BuildOrderedSecondaryPanelFileTabsArgs { + includeWorkspaceTabsOutsideEnvironment?: boolean; + tabs: readonly FixedPanelTab[]; + resolvedEnvironmentId: string | null | undefined; +} + +interface PruneStorageTabsArgs { + knownPaths: ReadonlySet; + tabs: readonly FixedPanelTab[]; + threadId: string | null | undefined; +} + +interface ReconcileFixedPanelViewTabsInStateArgs { + fixedTabs: readonly FixedPanelViewTab[]; + openFirstFixedTabWhenEmpty?: boolean; + state: FixedPanelTabsState; +} + +export function isWorkspaceFilePreviewTab( + tab: FixedPanelTab, +): tab is WorkspaceFilePreviewFixedPanelTab { + return tab.kind === "workspace-file-preview"; +} + +export function isStorageFilePreviewTab( + tab: FixedPanelTab, +): tab is ThreadStorageFilePreviewFixedPanelTab { + return tab.kind === "thread-storage-file-preview"; +} + +export function isBrowserTab(tab: FixedPanelTab): tab is BrowserFixedPanelTab { + return tab.kind === "browser"; +} + +export function isNewTab(tab: FixedPanelTab): tab is NewTabFixedPanelTab { + return tab.kind === "new-tab"; +} + +export function isSecondaryFileTab( + tab: FixedPanelTab, +): tab is SecondaryFileFixedPanelTab { + switch (tab.kind) { + case "workspace-file-preview": + case "host-file-preview": + case "thread-storage-file-preview": + case "browser": + case "terminal": + case "new-tab": + case "plugin-panel": + return true; + case "thread-info": + case "git-diff": + case "plugin-page-fixed": + return false; + } +} + +export function isFixedPanelViewTab( + tab: FixedPanelTab, +): tab is FixedPanelViewTab { + return !isSecondaryFileTab(tab); +} + +export function reconcileFixedPanelViewTabsInState({ + fixedTabs, + openFirstFixedTabWhenEmpty = false, + state, +}: ReconcileFixedPanelViewTabsInStateArgs): FixedPanelTabsState { + const contentTabs = state.secondary.tabs.filter(isSecondaryFileTab); + const tabs: readonly FixedPanelTab[] = [...fixedTabs, ...contentTabs]; + const tabsAreEquivalent = + tabs.length === state.secondary.tabs.length && + tabs.every((tab, index) => { + const current = state.secondary.tabs[index]; + return current !== undefined && areFixedPanelTabsEquivalent(tab, current); + }); + const activeTabStillExists = tabs.some( + (tab) => tab.id === state.secondary.activeTabId, + ); + const activeTabId = activeTabStillExists + ? state.secondary.activeTabId + : (fixedTabs[0]?.id ?? contentTabs[0]?.id ?? null); + const isFirstInitialization = + state.secondary.tabs.length === 0 && + state.secondary.activeTabId === null && + !state.secondary.isOpen; + const isOpen = + activeTabId !== null && + (state.secondary.isOpen || + (openFirstFixedTabWhenEmpty && + isFirstInitialization && + fixedTabs.length > 0)); + + if ( + tabsAreEquivalent && + activeTabId === state.secondary.activeTabId && + isOpen === state.secondary.isOpen + ) { + return state; + } + return setSecondaryPanelTabsInState({ + activeTabId, + isOpen, + state, + tabs, + }); +} + +export function getActiveSecondaryPanelTab( + state: FixedPanelTabsState, +): SecondaryFixedPanelTab | null { + const activeTabId = state.secondary.activeTabId; + if (activeTabId === null) { + return null; + } + return ( + state.secondary.tabs.find( + (tab): tab is SecondaryFixedPanelTab => tab.id === activeTabId, + ) ?? null + ); +} + +export function findSecondaryPanelTab( + tabs: readonly FixedPanelTab[], + tabId: string, +): FixedPanelTab | null { + return tabs.find((tab) => tab.id === tabId) ?? null; +} + +export function setSecondaryPanelTabsInState({ + activeTabId, + isOpen, + state, + tabs, +}: SetSecondaryPanelTabsInStateArgs): FixedPanelTabsState { + if ( + tabs === state.secondary.tabs && + activeTabId === state.secondary.activeTabId && + isOpen === state.secondary.isOpen + ) { + return state; + } + + return { + ...state, + secondary: { + tabs, + activeTabId, + isOpen, + }, + }; +} + +export function upsertSecondaryPanelTab( + tabs: readonly FixedPanelTab[], + tab: FixedPanelTab, +): readonly FixedPanelTab[] { + const existingTabIndex = tabs.findIndex( + (currentTab) => currentTab.id === tab.id, + ); + if (existingTabIndex === -1) { + return [...tabs, tab]; + } + + const existingTab = tabs[existingTabIndex]; + if (existingTab && areFixedPanelTabsEquivalent(existingTab, tab)) { + return tabs; + } + + return tabs.map((currentTab) => + currentTab.id === tab.id ? tab : currentTab, + ); +} + +export function removeSecondaryPanelTab( + tabs: readonly FixedPanelTab[], + tabId: string, +): readonly FixedPanelTab[] { + const nextTabs = tabs.filter((tab) => tab.id !== tabId); + return nextTabs.length === tabs.length ? tabs : nextTabs; +} + +export function openSecondaryPanelTabInState({ + state, + tab, +}: OpenSecondaryPanelTabInStateArgs): FixedPanelTabsState { + const tabs = upsertSecondaryPanelTab(state.secondary.tabs, tab); + if ( + tabs === state.secondary.tabs && + state.secondary.activeTabId === tab.id && + state.secondary.isOpen + ) { + return state; + } + return setSecondaryPanelTabsInState({ + activeTabId: tab.id, + isOpen: true, + state, + tabs, + }); +} + +export function replaceNewTabWithSecondaryPanelTabInState({ + state, + tab, +}: ReplaceNewTabWithSecondaryPanelTabInStateArgs): FixedPanelTabsState { + const newTab = state.secondary.tabs.find(isNewTab) ?? null; + const tabsWithoutNewTab = + newTab === null + ? state.secondary.tabs + : removeSecondaryPanelTab(state.secondary.tabs, newTab.id); + const existingPreviewTab = tabsWithoutNewTab.find( + (currentTab) => currentTab.id === tab.id, + ); + + if (existingPreviewTab) { + return setSecondaryPanelTabsInState({ + activeTabId: existingPreviewTab.id, + isOpen: true, + state, + tabs: tabsWithoutNewTab, + }); + } + + const tabs = + newTab === null + ? upsertSecondaryPanelTab(tabsWithoutNewTab, tab) + : state.secondary.tabs.map((currentTab) => + currentTab.id === newTab.id ? tab : currentTab, + ); + + return setSecondaryPanelTabsInState({ + activeTabId: tab.id, + isOpen: true, + state, + tabs, + }); +} + +export function updateSecondaryPanelTabInState({ + state, + tab, +}: UpdateSecondaryPanelTabInStateArgs): FixedPanelTabsState { + const tabs = upsertSecondaryPanelTab(state.secondary.tabs, tab); + if (tabs === state.secondary.tabs) { + return state; + } + return setSecondaryPanelTabsInState({ + activeTabId: state.secondary.activeTabId, + isOpen: state.secondary.isOpen, + state, + tabs, + }); +} + +export function activateSecondaryPanelTabInState( + state: FixedPanelTabsState, + tabId: string, +): FixedPanelTabsState { + const tab = findSecondaryPanelTab(state.secondary.tabs, tabId); + if (!tab) { + return state; + } + if (state.secondary.activeTabId === tab.id && state.secondary.isOpen) { + return state; + } + return setSecondaryPanelTabsInState({ + activeTabId: tab.id, + isOpen: true, + state, + tabs: state.secondary.tabs, + }); +} + +function getActiveTabIdAfterClose({ + activeTabId, + closedTabId, + tabsBeforeClose, + tabsAfterClose, +}: GetActiveTabIdAfterCloseArgs): string | null { + if (activeTabId !== closedTabId) { + return activeTabId; + } + + const fileTabsBeforeClose = tabsBeforeClose.filter(isSecondaryFileTab); + const closedFileTabIndex = fileTabsBeforeClose.findIndex( + (tab) => tab.id === closedTabId, + ); + if (closedFileTabIndex === -1) { + return null; + } + + const fileTabsAfterClose = tabsAfterClose.filter(isSecondaryFileTab); + const nextActiveTab = + fileTabsAfterClose[closedFileTabIndex] ?? + fileTabsAfterClose[closedFileTabIndex - 1] ?? + null; + return nextActiveTab?.id ?? null; +} + +export function closeSecondaryPanelTabInState( + state: FixedPanelTabsState, + tabId: string, +): FixedPanelTabsState { + const tab = findSecondaryPanelTab(state.secondary.tabs, tabId); + if (tab === null) { + return state; + } + const isClosingActiveTab = state.secondary.activeTabId === tabId; + + const tabs = removeSecondaryPanelTab(state.secondary.tabs, tabId); + if (tabs === state.secondary.tabs) { + return state; + } + + // Closing the last active content tab falls back to a remaining fixed tab. + // Only a genuinely empty panel closes; closing content never creates New tab. + if ( + isClosingActiveTab && + isSecondaryFileTab(tab) && + !tabs.some(isSecondaryFileTab) + ) { + const fallbackTab = tabs[0] ?? null; + return setSecondaryPanelTabsInState({ + activeTabId: fallbackTab?.id ?? null, + isOpen: fallbackTab !== null, + state, + tabs, + }); + } + + return setSecondaryPanelTabsInState({ + activeTabId: getActiveTabIdAfterClose({ + activeTabId: state.secondary.activeTabId, + closedTabId: tabId, + tabsBeforeClose: state.secondary.tabs, + tabsAfterClose: tabs, + }), + isOpen: state.secondary.isOpen, + state, + tabs, + }); +} + +export function reorderSecondaryPanelFileTabInState({ + activeTabId, + overTabId, + state, +}: ReorderSecondaryPanelFileTabInStateArgs): FixedPanelTabsState { + if (activeTabId === overTabId) { + return state; + } + const activeIndex = state.secondary.tabs.findIndex( + (tab) => tab.id === activeTabId && isSecondaryFileTab(tab), + ); + const overIndex = state.secondary.tabs.findIndex( + (tab) => tab.id === overTabId && isSecondaryFileTab(tab), + ); + if (activeIndex === -1 || overIndex === -1) { + return state; + } + return setSecondaryPanelTabsInState({ + activeTabId: state.secondary.activeTabId, + isOpen: state.secondary.isOpen, + state, + tabs: arrayMove([...state.secondary.tabs], activeIndex, overIndex), + }); +} + +export function clearActiveSecondaryFileTabInState( + state: FixedPanelTabsState, +): FixedPanelTabsState { + const activeTab = getActiveSecondaryPanelTab(state); + if (!activeTab || !isSecondaryFileTab(activeTab)) { + return state; + } + return setSecondaryPanelTabsInState({ + activeTabId: null, + isOpen: state.secondary.isOpen, + state, + tabs: state.secondary.tabs, + }); +} + +export function removeWorkspaceTabsForOtherEnvironments( + tabs: readonly FixedPanelTab[], + environmentId: string | null, +): readonly FixedPanelTab[] { + const nextTabs = tabs.filter( + (tab) => + !isWorkspaceFilePreviewTab(tab) || tab.environmentId === environmentId, + ); + return nextTabs.length === tabs.length ? tabs : nextTabs; +} + +export function pruneStorageTabs({ + knownPaths, + tabs, + threadId, +}: PruneStorageTabsArgs): readonly FixedPanelTab[] { + const nextTabs = tabs.filter( + (tab) => + !isStorageFilePreviewTab(tab) || + (tab.threadId !== null && tab.threadId !== threadId) || + knownPaths.has(tab.path), + ); + return nextTabs.length === tabs.length ? tabs : nextTabs; +} + +export function getActiveTabIdAfterPrune( + tabs: readonly FixedPanelTab[], + activeTabId: string | null, +): string | null { + return activeTabId !== null && tabs.some((tab) => tab.id === activeTabId) + ? activeTabId + : null; +} + +export function buildOrderedSecondaryPanelFileTabs({ + includeWorkspaceTabsOutsideEnvironment = false, + tabs, + resolvedEnvironmentId, +}: BuildOrderedSecondaryPanelFileTabsArgs): readonly SecondaryFileFixedPanelTab[] { + const displayable: SecondaryFileFixedPanelTab[] = []; + for (const tab of tabs) { + switch (tab.kind) { + case "workspace-file-preview": + if ( + includeWorkspaceTabsOutsideEnvironment || + (resolvedEnvironmentId !== undefined && + tab.environmentId === resolvedEnvironmentId) + ) { + displayable.push(tab); + } + break; + case "host-file-preview": + case "browser": + case "terminal": + case "new-tab": + case "thread-storage-file-preview": + case "plugin-panel": + displayable.push(tab); + break; + case "thread-info": + case "git-diff": + case "plugin-page-fixed": + break; + } + } + return displayable; +} diff --git a/packages/client-core/src/prompt/automation-prompt.ts b/packages/client-core/src/prompt/automation-prompt.ts new file mode 100644 index 0000000000..be8d347828 --- /dev/null +++ b/packages/client-core/src/prompt/automation-prompt.ts @@ -0,0 +1,17 @@ +import type { PromptMentionResource } from "@bb/domain"; +import { CREATE_AUTOMATION_PROMPT } from "./create-resource-prompts.js"; + +export const SUBMITTED_AUTOMATION_PROMPT_PREFIX = + CREATE_AUTOMATION_PROMPT.trimEnd(); + +export function isAutomationPromptCommandResource( + resource: PromptMentionResource, +): boolean { + return ( + resource.kind === "command" && + resource.trigger === "/" && + (resource.name === "automation" || resource.name === "loop") && + resource.source === "command" && + resource.origin === "user" + ); +} diff --git a/packages/client-core/src/prompt/create-resource-prompts.ts b/packages/client-core/src/prompt/create-resource-prompts.ts new file mode 100644 index 0000000000..3142ba67a8 --- /dev/null +++ b/packages/client-core/src/prompt/create-resource-prompts.ts @@ -0,0 +1,10 @@ +/** + * The prompt prefixes that seed the composer when the user asks bb to create + * one of its own resources. Every entry point for a kind — library button, + * settings button, composer menu — uses the same prefix, so the instruction the + * agent reads does not drift between surfaces. + */ + +export const CREATE_SKILL_PROMPT = "Create a new bb skill that "; +export const CREATE_AUTOMATION_PROMPT = "Create a new bb automation to "; +export const CREATE_PLUGIN_PROMPT = "Create a new bb plugin that "; diff --git a/packages/client-core/src/prompt/effective-prompt-mode.ts b/packages/client-core/src/prompt/effective-prompt-mode.ts new file mode 100644 index 0000000000..b0744a105d --- /dev/null +++ b/packages/client-core/src/prompt/effective-prompt-mode.ts @@ -0,0 +1,71 @@ +import { + promptInputHasCommandMention, + type ThreadTimelineActivePromptMode, + type PromptTextMention, +} from "@bb/domain"; + +export interface PromptModeInput { + mentionRanges: readonly PromptTextMention[]; + providerId: string | undefined; + value: string; +} + +export interface PermissionDisplayOverride { + label: string; + compactLabel?: string; + description?: string; + title?: string; +} + +const CLAUDE_PLAN_PERMISSION_DISPLAY: PermissionDisplayOverride = { + label: "Plan Mode", + compactLabel: "Plan", + description: "Claude Code will plan without normal full-access execution.", +}; + +export function isClaudePlanModePrompt({ + mentionRanges, + providerId, + value, +}: PromptModeInput): boolean { + return ( + providerId === "claude-code" && + promptInputHasCommandMention( + [{ type: "text", text: value, mentions: [...mentionRanges] }], + { trigger: "/", name: "plan" }, + ) + ); +} + +export function permissionDisplayForPromptMode( + args: PromptModeInput, +): PermissionDisplayOverride | undefined { + if (!isClaudePlanModePrompt(args)) { + return undefined; + } + return CLAUDE_PLAN_PERMISSION_DISPLAY; +} + +export function permissionDisplayForActivePromptMode( + activePromptMode: ThreadTimelineActivePromptMode | null | undefined, +): PermissionDisplayOverride | undefined { + if ( + activePromptMode?.mode === "plan" && + activePromptMode.providerId === "claude-code" + ) { + return CLAUDE_PLAN_PERMISSION_DISPLAY; + } + return undefined; +} + +export function shouldDisablePermissionPickerForPromptMode( + args: PromptModeInput, +): boolean { + return isClaudePlanModePrompt(args); +} + +export function shouldDisablePermissionPickerForActivePromptMode( + activePromptMode: ThreadTimelineActivePromptMode | null | undefined, +): boolean { + return activePromptMode?.mode === "plan"; +} diff --git a/packages/client-core/src/prompt/follow-up-submit-mode.ts b/packages/client-core/src/prompt/follow-up-submit-mode.ts new file mode 100644 index 0000000000..8e5ed69430 --- /dev/null +++ b/packages/client-core/src/prompt/follow-up-submit-mode.ts @@ -0,0 +1,24 @@ +/** + * Discriminated state for the composer's submit affordances. Replaces the + * previous canSendFollowUp / canQueueFollowUp / canStopRuntime / onStop + * boolean soup. The caller computes one of these from runtimeDisplayStatus + + * pending-interaction state and passes it down; the composer reads .kind to + * render submit/queue/stop affordances. + */ +export type FollowUpBlockedReason = + | "loading-execution-options" + | "loading-pending-interactions" + | "pending-interaction" + | "provisioning" + | "stopping" + | "unavailable"; + +export type FollowUpSubmitMode = + /** Idle thread — submit creates a new turn; no stop affordance. */ + | { kind: "ready" } + /** Runtime is active or host-reconnecting — submit queues the message; stop the runtime. */ + | { kind: "queue"; onStop: () => void } + /** Runtime is pre-start or waiting on the host — can't send/queue, but can stop. */ + | { kind: "stop-only"; onStop: () => void } + /** Can't submit and can't stop — show why. */ + | { kind: "blocked"; reason: FollowUpBlockedReason }; diff --git a/packages/client-core/src/prompt/fork-thread-request.ts b/packages/client-core/src/prompt/fork-thread-request.ts new file mode 100644 index 0000000000..b2e00c86ea --- /dev/null +++ b/packages/client-core/src/prompt/fork-thread-request.ts @@ -0,0 +1,87 @@ +import type { + PermissionMode, + PromptInput, + ReasoningLevel, + ServiceTier, + Thread, +} from "@bb/domain"; +import type { AppCreateThreadRequest } from "../api-types.js"; + +export const FORK_THREAD_CREATE_SEED_LOCATION_STATE_KEY = + "forkThreadCreateSeed"; + +export interface ForkThreadCreateSeed { + environmentId: string; + model: string; + permissionMode: PermissionMode; + projectId: string; + providerId: string; + reasoningLevel: ReasoningLevel; + serviceTier: ServiceTier | undefined; + sourceSeqEnd: number | undefined; + sourceThreadId: string; + sourceThreadTitle: string; +} + +export interface BuildForkThreadRequestArgs extends ForkThreadCreateSeed { + input: PromptInput[]; + /** + * The source thread provider's `capabilities.supportsFork`, read from the + * server-provided ProviderInfo (execution-options query data). False when + * the provider is unknown or its data has not loaded — graceful absence. + */ + providerSupportsFork: boolean; +} + +type ForkableThread = Pick; + +export function isThreadForkable( + sourceThread: ForkableThread | null, + providerSupportsFork: boolean, +): boolean { + if (sourceThread === null || sourceThread.environmentId === null) { + return false; + } + return providerSupportsFork; +} + +export function buildForkThreadRequest({ + environmentId, + input, + model, + permissionMode, + projectId, + providerId, + providerSupportsFork, + reasoningLevel, + serviceTier, + sourceSeqEnd, + sourceThreadId, +}: BuildForkThreadRequestArgs): AppCreateThreadRequest | null { + if ( + !isThreadForkable( + { + environmentId, + providerId, + }, + providerSupportsFork, + ) + ) { + return null; + } + + return { + environment: { type: "reuse", environmentId }, + input, + model, + originKind: "fork", + permissionMode, + projectId, + providerId, + reasoningLevel, + ...(serviceTier ? { serviceTier } : {}), + ...(sourceSeqEnd !== undefined ? { sourceSeqEnd } : {}), + sourceThreadId, + startedOnBehalfOf: null, + }; +} diff --git a/packages/client-core/src/prompt/mentions/command-trigger.ts b/packages/client-core/src/prompt/mentions/command-trigger.ts new file mode 100644 index 0000000000..3d60537aff --- /dev/null +++ b/packages/client-core/src/prompt/mentions/command-trigger.ts @@ -0,0 +1,72 @@ +import type { + ProviderComposerCommand, + PromptMentionCommandTrigger, + ProviderComposerAction, +} from "@bb/domain"; + +export type ProviderPromptActionCommand = ProviderComposerCommand; + +export interface ProviderPromptAction { + kind: "goal" | "plan" | "skills"; + text: string; + command?: ProviderPromptActionCommand; +} + +export interface ProviderPromptActionProps { + skillsTrigger: PromptMentionCommandTrigger | null; + promptActions: readonly ProviderPromptAction[]; +} + +/** + * Maps provider-owned composer metadata into the prompt action shape consumed + * by app hosts. + */ +export function buildProviderPromptActionProps( + composerActions: readonly ProviderComposerAction[], +): ProviderPromptActionProps { + const promptActions: ProviderPromptAction[] = []; + let skillsTrigger: PromptMentionCommandTrigger | null = null; + + for (const action of composerActions) { + switch (action.kind) { + case "skills": + skillsTrigger = action.trigger; + promptActions.push({ + kind: action.kind, + text: action.trigger, + }); + break; + case "goal": + case "plan": + promptActions.push({ + kind: action.kind, + command: action.command, + text: serializedProviderCommand(action.command), + }); + break; + } + } + + return { skillsTrigger, promptActions }; +} + +export function serializedProviderCommand( + command: ProviderComposerCommand, +): string { + return `${command.trigger}${command.name}${command.trailingText}`; +} + +/** + * A selected command is a one-position mention atom in the editor doc. The + * dismissed range is based on that rendered node width plus any space inserted + * after it, not on the serialized provider token length (`/review`, etc.). + */ +export function commandPillDismissedRangeEnd({ + triggerPosition, + trailingText, +}: { + triggerPosition: number; + trailingText: string; +}): number { + return triggerPosition + 1 + trailingText.length; +} diff --git a/packages/client-core/src/prompt/mentions/find-active-trigger.ts b/packages/client-core/src/prompt/mentions/find-active-trigger.ts new file mode 100644 index 0000000000..00f5c26791 --- /dev/null +++ b/packages/client-core/src/prompt/mentions/find-active-trigger.ts @@ -0,0 +1,120 @@ +import type { ActiveTrigger, TypeaheadTrigger } from "./types.js"; + +/** + * The slice of a rich-text editor the trigger scanner reads. Structurally + * satisfied by a TipTap `Editor` on the web; native composers supply the same + * shape over their own selection + text model. + */ +export interface ActiveTriggerEditor { + state: { + selection: { + empty: boolean; + from: number; + }; + doc: { + textBetween( + from: number, + to: number, + blockSeparator?: string, + leafText?: string, + ): string; + }; + }; +} + +/** + * Builds the word-boundary detection regex for a trigger char. A trigger only + * fires at the start of input or after whitespace / an opening bracket, so a + * mid-word `a/b` or `foo@bar` never opens a menu. + * + * - mention triggers keep a per-char self-exclusion query class, so a second + * trigger char ends the current query rather than extending it (`##` stays a + * markdown heading, not a `#` mention query). + * - command triggers (`/`) capture the whole token up to whitespace + * (`\S*`), so a namespaced name like `frontend:component` is captured whole. + */ +function escapeRegexLiteral(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); +} + +function triggerPattern( + trigger: TypeaheadTrigger, + options: { windowed: boolean }, +): RegExp { + const escapedChar = escapeRegexLiteral(trigger.char); + const queryClass = + trigger.kind === "mention" ? `[^\\s${escapedChar}]*` : "\\S*"; + // In a windowed scan the window start is not the start of input, so the + // `^` alternative must not fire there; a real trigger inside the window + // always carries its boundary char (the window includes one extra char + // beyond the longest recognizable query). + const boundary = options.windowed ? "([\\s([{])" : "(^|[\\s([{])"; + return new RegExp(`${boundary}${escapedChar}(${queryClass})$`, "u"); +} + +/** + * How many characters before the caret are scanned for a trigger. Trigger + * queries are short human-typed tokens (skill/command names, mention + * queries); scanning the full document instead would rebuild and regex-scan + * the entire text on every keystroke and selection change, which costs + * several ms once a large paste (e.g. a minified JS bundle) is in the box. A + * trigger whose query exceeds the window no longer opens the menu — at that + * length no menu has useful matches anyway. + */ +const TRIGGER_SCAN_WINDOW = 256; + +/** + * Resolves the typeahead trigger currently under the caret, if any. Replaces the + * single-`@` `findActiveEditorMention`: it scans the configured `triggers` in + * order and returns the first whose pattern matches the text before the caret. + * Because a thread is bound to one provider, the active set is at most `@` plus + * one command trigger, so order only matters when both could match (they can't — + * the leading char differs). + * + * Returns `null` when the selection is non-empty (a range, not a caret) or no + * trigger matches. + */ +export function findActiveTrigger( + editor: ActiveTriggerEditor, + triggers: readonly TypeaheadTrigger[], +): ActiveTrigger | null { + const selection = editor.state.selection; + if (!selection.empty) return null; + + const scanStart = Math.max(0, selection.from - TRIGGER_SCAN_WINDOW); + const windowed = scanStart > 0; + const textBeforeCursor = editor.state.doc.textBetween( + scanStart, + selection.from, + "\n", + "\n", + ); + + for (const trigger of triggers) { + const match = triggerPattern(trigger, { windowed }).exec(textBeforeCursor); + if (!match) continue; + + const query = match[2] ?? ""; + const from = selection.from - query.length - 1; + if (from < 0) continue; + + if (trigger.kind === "mention") { + return { + char: trigger.char, + kind: "mention", + query, + from, + to: selection.from, + }; + } + return { + char: trigger.char, + kind: "command", + query, + from, + to: selection.from, + }; + } + + return null; +} diff --git a/packages/client-core/src/prompt/mentions/plugin-mention-triggers.ts b/packages/client-core/src/prompt/mentions/plugin-mention-triggers.ts new file mode 100644 index 0000000000..8040072fbb --- /dev/null +++ b/packages/client-core/src/prompt/mentions/plugin-mention-triggers.ts @@ -0,0 +1,44 @@ +export type PluginMentionTrigger = "@" | "#" | "$" | "!" | "~"; + +export const DEFAULT_PLUGIN_MENTION_TRIGGER: PluginMentionTrigger = "@"; +export const PLUGIN_MENTION_TRIGGER_VALUES = [ + "@", + "#", + "$", + "!", + "~", +] as const satisfies readonly PluginMentionTrigger[]; + +export function isPluginMentionTrigger( + value: unknown, +): value is PluginMentionTrigger { + switch (value) { + case "@": + case "#": + case "$": + case "!": + case "~": + return true; + default: + return false; + } +} + +export function normalizePluginMentionTriggers( + value: unknown, +): readonly PluginMentionTrigger[] | null { + if (value === undefined) { + return [DEFAULT_PLUGIN_MENTION_TRIGGER]; + } + if (!Array.isArray(value) || value.length === 0) { + return null; + } + const triggers: PluginMentionTrigger[] = []; + for (const trigger of value) { + if (!isPluginMentionTrigger(trigger) || triggers.includes(trigger)) { + return null; + } + triggers.push(trigger); + } + return triggers; +} diff --git a/packages/client-core/src/prompt/mentions/types.ts b/packages/client-core/src/prompt/mentions/types.ts new file mode 100644 index 0000000000..c1f06c302c --- /dev/null +++ b/packages/client-core/src/prompt/mentions/types.ts @@ -0,0 +1,296 @@ +import { + providerCommandSection, + providerCommandSectionRank, + type ProviderCommand, + type ProviderCommandOrigin, + type ProviderCommandSection, + type ProviderCommandSource, +} from "@bb/server-contract"; +import type { PromptMentionCommandTrigger } from "@bb/domain"; +import type { PluginMentionTrigger } from "./plugin-mention-triggers.js"; + +export type PromptPathMentionSource = "workspace" | "thread-storage"; +export type PromptPathMentionEntryKind = "file" | "directory"; + +/** + * One row in the mention menu. The `replacement` field is the literal text + * inserted into the prompt after the user picks the suggestion (e.g. + * `apps/app/src/foo.ts` for workspace files, + * `thread-storage:notes/foo.md` for thread-storage files, + * `thread:thr_abc` for threads, or `project:proj_abc` for projects). + */ +export type PromptMentionSuggestion = + | { + kind: "path"; + source: PromptPathMentionSource; + entryKind: PromptPathMentionEntryKind; + path: string; + name: string; + replacement: string; + } + | { + kind: "thread"; + path: string; + replacement: string; + projectId: string; + projectName?: string; + threadId: string; + title?: string; + } + | { + kind: "project"; + path: string; + replacement: string; + projectId: string; + name: string; + } + | { + kind: "section"; + path: string; + replacement: string; + sectionId: string; + name: string; + } + | { + /** + * One plugin mention-provider row (plugin design §4.9), from + * GET /plugins/mentions/search. Items group under `providerLabel` in + * the menu; picking one inserts a pill whose resource carries + * `pluginId` + the opaque `itemId` the server resolves at send time. + */ + kind: "plugin"; + pluginId: string; + /** Provider id within the plugin; with pluginId it identifies the + * menu section (labels alone can collide across plugins). */ + providerId: string; + itemId: string; + providerLabel: string; + title: string; + subtitle: string | null; + /** Named shared-UI icon hint supplied by the plugin item. */ + icon: string | null; + replacement: string; + }; + +/** + * One row in the command typeahead menu, derived from a {@link ProviderCommand} + * returned by `GET /projects/:id/commands`. The `kind: "command"` discriminant + * lets it join the same menu union as {@link PromptMentionSuggestion} while the + * composer's apply path inserts a prompt pill that serializes back to the + * slash command token (`/`). + */ +export interface ProviderCommandSuggestion { + kind: "command"; + name: string; + source: ProviderCommandSource; + origin: ProviderCommandOrigin; + description: string | null; + argumentHint: string | null; + pluginId?: string; +} + +/** + * Build a {@link ProviderCommandSuggestion} from the wire-level + * {@link ProviderCommand}. The only difference is the `kind` discriminant that + * slots the record into the menu's suggestion union. + */ +export function toProviderCommandSuggestion( + command: ProviderCommand, +): ProviderCommandSuggestion { + return { + kind: "command", + name: command.name, + source: command.source, + origin: command.origin, + description: command.description, + argumentHint: command.argumentHint, + ...(command.pluginId !== undefined ? { pluginId: command.pluginId } : {}), + }; +} + +/** Every row the command typeahead menu can render. */ +export type ComposerCommandSuggestion = ProviderCommandSuggestion; + +function compareCommandSuggestionSections( + left: ComposerCommandSuggestion, + right: ComposerCommandSuggestion, +): number { + return providerCommandSectionRank(left) - providerCommandSectionRank(right); +} + +/** + * The names a query can address a command by. A namespaced skill + * (`ottonomous:review`) also answers to its trailing segment, so typing the + * bare name still counts as naming that skill. + */ +function commandSuggestionSearchNames( + suggestion: ComposerCommandSuggestion, +): string[] { + const name = suggestion.name.toLowerCase(); + if (suggestion.source !== "skill") { + return [name]; + } + const separatorIndex = name.lastIndexOf(":"); + return separatorIndex < 0 ? [name] : [name, name.slice(separatorIndex + 1)]; +} + +/** + * How directly the query names a command. Lower wins: the whole canonical + * name, then a namespaced skill's bare alias, then a name prefix, then a row + * that only matched through its description or argument hint. An empty query + * prefix-matches everything, so it ranks every row alike. + */ +function commandSuggestionMatchRank( + suggestion: ComposerCommandSuggestion, + normalizedQuery: string, +): number { + const canonicalName = suggestion.name.toLowerCase(); + if (canonicalName === normalizedQuery) { + return 0; + } + const names = commandSuggestionSearchNames(suggestion); + if (names.includes(normalizedQuery)) { + return 1; + } + return names.some((name) => name.startsWith(normalizedQuery)) ? 2 : 3; +} + +/** + * Relevance order for a lowercased, trimmed query. How directly the query names + * a command outranks which section that command lives in: typing `/plan` in full + * is an unambiguous request for the `plan` user command, and even a partial + * `/pla` names it more directly than a skill that merely mentions "plan" in its + * description. Matches of equal quality keep the `PROVIDER_COMMAND_SECTIONS` + * order, so an empty query — which prefix-matches every row — leaves pure + * section order. + */ +export function compareCommandSuggestions( + left: ComposerCommandSuggestion, + right: ComposerCommandSuggestion, + normalizedQuery: string, +): number { + const byMatch = + commandSuggestionMatchRank(left, normalizedQuery) - + commandSuggestionMatchRank(right, normalizedQuery); + return byMatch !== 0 + ? byMatch + : compareCommandSuggestionSections(left, right); +} + +/** + * Put the flat command list in the exact order the menu renders it: ranked by + * {@link compareCommandSuggestions}, then collapsed so every section's rows are + * contiguous, ordered by where that section first appears — which puts each + * section under its own best match. The collapse is what keeps hoisting a strong + * match honest: the menu groups by section as it renders, so a section whose + * rows were scattered through the flat list would paint them in a different + * order than the composer walks them. The composer uses this exact array for + * keyboard navigation and Enter/Tab apply, so visual grouping must never be the + * first place ordering happens. + */ +export function orderCommandSuggestions( + suggestions: readonly ComposerCommandSuggestion[], + query: string, +): ComposerCommandSuggestion[] { + const normalizedQuery = query.trim().toLowerCase(); + const ranked = [...suggestions].sort((left, right) => + compareCommandSuggestions(left, right, normalizedQuery), + ); + + const bySection = new Map< + ProviderCommandSection, + ComposerCommandSuggestion[] + >(); + for (const suggestion of ranked) { + const section = providerCommandSection(suggestion); + const existing = bySection.get(section); + if (existing) { + existing.push(suggestion); + continue; + } + bySection.set(section, [suggestion]); + } + return [...bySection.values()].flat(); +} + +/** + * A typeahead trigger the composer watches for. Mention triggers open the + * mention menu and the provider-owned command trigger opens the command menu. + * A thread is bound to one provider, so at most one command trigger is ever + * active in a composer. + */ +export type TypeaheadTrigger = + | { char: PluginMentionTrigger; kind: "mention" } + | { char: PromptMentionCommandTrigger; kind: "command" }; + +/** + * The trigger currently under the caret, resolved by the composer's + * word-boundary detection. `from` is the document position of the trigger char + * and `to` is the caret position; `query` is the text typed after the trigger + * up to the caret (whole namespaced names like `frontend:component` are + * captured, stopping at whitespace). + */ +export type ActiveTrigger = + | { + char: PluginMentionTrigger; + kind: "mention"; + query: string; + from: number; + to: number; + } + | { + char: PromptMentionCommandTrigger; + kind: "command"; + query: string; + from: number; + to: number; + }; + +/** + * Mutually-exclusive states the mention menu can render. Replaces the prior + * 4-boolean flag soup (showQueryHint / mentionLoading / mentionError / + * mentionSuggestions). The "results" state's empty-vs-populated rendering is + * a single decision inside the menu (`suggestions.length === 0` shows the + * empty state). + */ +export type MentionMenuState = + /** User typed `@` but no query yet. */ + | { kind: "hint" } + /** Suggestions request in flight. */ + | { kind: "loading" } + /** Suggestions request failed. */ + | { kind: "error" } + /** Suggestions resolved (possibly empty). */ + | { + kind: "results"; + suggestions: readonly PromptMentionSuggestion[]; + }; + +/** + * Mutually-exclusive states the command typeahead menu can render. Mirrors + * {@link MentionMenuState} but with no "hint" state: command triggers show the + * full available list immediately (no "type to search" gate). The composer + * suppresses opening the menu entirely on a loaded-empty result, so an empty + * `results` state is only reached transiently. + */ +export type CommandMenuState = + /** Suggestions request in flight. */ + | { kind: "loading" } + /** Suggestions request failed. */ + | { kind: "error" } + /** Suggestions resolved (possibly empty). */ + | { + kind: "results"; + suggestions: readonly ComposerCommandSuggestion[]; + }; + +/** + * Generalized typeahead menu state covering both trigger kinds. The `trigger` + * discriminant tells the menu which suggestion shape it is rendering so a + * single `MentionMenu` can present mention sections or command sections without + * forking. The §6 menu task consumes this; §5 composer task produces it from + * the active trigger plus the matching data hook. + */ +export type TypeaheadMenuState = + | { trigger: "mention"; state: MentionMenuState } + | { trigger: "command"; state: CommandMenuState }; diff --git a/packages/client-core/src/prompt/permission-mode-options.ts b/packages/client-core/src/prompt/permission-mode-options.ts new file mode 100644 index 0000000000..1c8276af64 --- /dev/null +++ b/packages/client-core/src/prompt/permission-mode-options.ts @@ -0,0 +1,40 @@ +import type { PermissionMode } from "@bb/domain"; + +/** + * A permission mode as the pickers list it. Structurally a subset of the web + * `PickerOption`, so the shared list can feed both the web pickers and native + * sheets without depending on a rendering library. + */ +export interface PermissionModeOption { + value: PermissionMode; + label: string; + description: string; + tone?: "warning"; +} + +/** + * The permission modes as the user sees them. Shared by the composer pickers + * (which pick the mode a thread runs with) and Settings → Machines (which + * picks the highest mode a machine allows), so the two never drift. + */ +export const PERMISSION_MODE_OPTIONS: PermissionModeOption[] = [ + { + value: "accept-edits", + label: "Accept Edits", + description: + "Applies edits inside the workspace automatically. Anything beyond the workspace asks you first.", + }, + { + value: "auto", + label: "Approve for me", + description: + "Same workspace sandbox, with requests reviewed automatically. High-risk actions can still come back to you.", + }, + { + value: "full", + label: "Full Access", + tone: "warning", + description: + "No sandbox and no approvals — the agent can run anything on your machine.", + }, +]; diff --git a/packages/client-core/src/prompt/prompt-draft.ts b/packages/client-core/src/prompt/prompt-draft.ts new file mode 100644 index 0000000000..6e548505d4 --- /dev/null +++ b/packages/client-core/src/prompt/prompt-draft.ts @@ -0,0 +1,393 @@ +import { + promptTextMentionSchema, + type PromptInput, + type PromptTextMention, +} from "@bb/domain"; +import { + uploadedPromptAttachmentSchema, + type UploadedPromptAttachment, +} from "@bb/server-contract"; +import { z } from "zod"; +import { + isAutomationPromptCommandResource, + SUBMITTED_AUTOMATION_PROMPT_PREFIX, +} from "./automation-prompt.js"; + +export type PromptDraftAttachment = UploadedPromptAttachment; + +export interface PromptDraftState { + text: string; + mentions: PromptTextMention[]; + attachments: PromptDraftAttachment[]; +} + +const promptDraftStorageSchema = z.object({ + text: z.string().default(""), + mentions: z + .array(z.unknown()) + .default([]) + .transform((items) => + items.flatMap((item) => { + const result = promptTextMentionSchema.safeParse(item); + return result.success ? [result.data] : []; + }), + ), + attachments: z + .array(z.unknown()) + .default([]) + .transform((items) => + items.flatMap((item) => { + const result = uploadedPromptAttachmentSchema.safeParse(item); + return result.success ? [result.data] : []; + }), + ), +}); + +export function emptyPromptDraftState(): PromptDraftState { + return { + text: "", + mentions: [], + attachments: [], + }; +} + +function normalizeQuotedSelectionText(text: string): string { + const lines = text.replace(/\r\n|\r/gu, "\n").split("\n"); + const normalizedLines: string[] = []; + + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]!; + const previousLine = normalizedLines.at(-1); + const nextLine = lines[index + 1]; + if ( + line.trim().length === 0 && + previousLine?.startsWith(">") === true && + nextLine?.startsWith(">") === true + ) { + continue; + } + normalizedLines.push(line); + } + + return normalizedLines.join("\n").trim(); +} + +/** + * Append a quoted selection to the draft text as a `> `-prefixed blockquote + * block. The editor parses these blocks into real blockquote nodes; the user + * types their reply in the paragraph below. Appending to the END of the text + * keeps every existing mention offset unchanged. + */ +export function appendQuoteToDraftText( + state: PromptDraftState, + quotedText: string, +): PromptDraftState { + // Guard the boundary: an empty/whitespace-only selection would otherwise + // emit a bare "> " block and make an empty draft look dirty. + const trimmed = normalizeQuotedSelectionText(quotedText); + if (trimmed === "") return state; + + const block = trimmed + .split("\n") + .map((line) => (line.length > 0 ? `> ${line}` : ">")) + .join("\n"); + + // Trailing newline so the reply paragraph sits below the quote. + const text = state.text === "" ? `${block}\n` : `${state.text}\n${block}\n`; + + return { ...state, text }; +} + +export function appendQuoteAndAttachmentsToDraft( + state: PromptDraftState, + quotedText: string, + attachments: readonly PromptDraftAttachment[], +): PromptDraftState { + const quotedState = appendQuoteToDraftText(state, quotedText); + if (attachments.length === 0) { + return quotedState; + } + + const existingAttachmentPaths = new Set( + quotedState.attachments.map((attachment) => attachment.path), + ); + const mergedAttachments = [...quotedState.attachments]; + for (const attachment of attachments) { + if (existingAttachmentPaths.has(attachment.path)) { + continue; + } + existingAttachmentPaths.add(attachment.path); + mergedAttachments.push(attachment); + } + + if (mergedAttachments.length === quotedState.attachments.length) { + return quotedState; + } + + return { ...quotedState, attachments: mergedAttachments }; +} + +export function isPromptDraftEmpty(draft: PromptDraftState): boolean { + return ( + draft.text.length === 0 && + draft.mentions.length === 0 && + draft.attachments.length === 0 + ); +} + +export function parsePromptDraftStorage( + rawValue: string | null, +): PromptDraftState { + if (!rawValue) return emptyPromptDraftState(); + + try { + const parsed: unknown = JSON.parse(rawValue); + const result = promptDraftStorageSchema.safeParse(parsed); + return result.success ? result.data : emptyPromptDraftState(); + } catch { + return emptyPromptDraftState(); + } +} + +export function serializePromptDraftStorage( + draft: PromptDraftState, +): string | null { + const text = draft.text; + const mentions = draft.mentions; + const attachments = draft.attachments; + if (isPromptDraftEmpty(draft)) { + return null; + } + return JSON.stringify({ + text, + ...(mentions.length > 0 ? { mentions } : {}), + attachments, + }); +} + +export function arePromptDraftStatesEqual( + left: PromptDraftState, + right: PromptDraftState, +): boolean { + return ( + serializePromptDraftStorage(left) === serializePromptDraftStorage(right) + ); +} + +function getFileNameFromPath(path: string): string { + const trimmedPath = path.trim(); + if (trimmedPath.length === 0) { + return "Attachment"; + } + + const segments = trimmedPath.split("/"); + const lastSegment = segments[segments.length - 1]; + return lastSegment && lastSegment.length > 0 ? lastSegment : trimmedPath; +} + +function normalizePromptTextMentions( + mentions: readonly PromptTextMention[], + textLength: number, +): PromptTextMention[] { + return mentions + .filter( + (mention) => + mention.start >= 0 && + mention.end > mention.start && + mention.end <= textLength, + ) + .sort((left, right) => left.start - right.start || left.end - right.end); +} + +interface ExpandedPromptText { + text: string; + mentions: PromptTextMention[]; +} + +function expandAutomationPromptCommandMentions( + text: string, + mentions: readonly PromptTextMention[], +): ExpandedPromptText { + const automationMentions = mentions + .filter((mention) => isAutomationPromptCommandResource(mention.resource)) + .sort((left, right) => left.start - right.start || left.end - right.end); + + if (automationMentions.length === 0) { + return { text, mentions: [...mentions] }; + } + + const replacements: Array<{ start: number; end: number }> = []; + let cursor = 0; + let nextText = ""; + for (const mention of automationMentions) { + if (mention.start < cursor) { + continue; + } + replacements.push({ start: mention.start, end: mention.end }); + nextText += text.slice(cursor, mention.start); + nextText += SUBMITTED_AUTOMATION_PROMPT_PREFIX; + cursor = mention.end; + } + nextText += text.slice(cursor); + + const nextMentions = mentions.flatMap((mention) => { + if (isAutomationPromptCommandResource(mention.resource)) { + return []; + } + + let offset = 0; + for (const replacement of replacements) { + if (mention.start < replacement.end && mention.end > replacement.start) { + return []; + } + if (replacement.end <= mention.start) { + offset += + SUBMITTED_AUTOMATION_PROMPT_PREFIX.length - + (replacement.end - replacement.start); + } + } + + return [ + { + ...mention, + start: mention.start + offset, + end: mention.end + offset, + }, + ]; + }); + + return { + text: nextText, + mentions: normalizePromptTextMentions(nextMentions, nextText.length), + }; +} + +export function promptDraftToInput(draft: PromptDraftState): PromptInput[] { + const input: PromptInput[] = []; + + const trimStartLength = draft.text.length - draft.text.trimStart().length; + const trimEndIndex = draft.text.trimEnd().length; + const text = draft.text.slice(trimStartLength, trimEndIndex); + if (text.length > 0) { + const mentions = normalizePromptTextMentions( + draft.mentions.flatMap((mention) => { + const visibleStart = Math.max(mention.start, trimStartLength); + const visibleEnd = Math.min(mention.end, trimEndIndex); + return visibleStart < visibleEnd + ? [ + { + ...mention, + start: visibleStart - trimStartLength, + end: visibleEnd - trimStartLength, + }, + ] + : []; + }), + text.length, + ); + const expandedText = expandAutomationPromptCommandMentions(text, mentions); + input.push({ + type: "text", + text: expandedText.text, + mentions: expandedText.mentions, + }); + } + + for (const attachment of draft.attachments) { + if (attachment.type === "localImage") { + input.push({ + type: "localImage", + path: attachment.path, + }); + continue; + } + + input.push({ + type: "localFile", + path: attachment.path, + name: attachment.name, + ...(attachment.sizeBytes > 0 ? { sizeBytes: attachment.sizeBytes } : {}), + ...(attachment.mimeType ? { mimeType: attachment.mimeType } : {}), + }); + } + + return input; +} + +export function promptInputToDraft( + input: readonly PromptInput[], +): PromptDraftState { + const textSegments: string[] = []; + const mentions: PromptTextMention[] = []; + const attachments: PromptDraftState["attachments"] = []; + let textOffset = 0; + + for (const chunk of input) { + if (chunk.type === "text") { + if (chunk.text.trim().length > 0) { + if (textSegments.length > 0) { + textOffset += 2; + } + for (const mention of chunk.mentions) { + if ( + mention.start >= 0 && + mention.end > mention.start && + mention.end <= chunk.text.length + ) { + mentions.push({ + ...mention, + start: textOffset + mention.start, + end: textOffset + mention.end, + }); + } + } + textSegments.push(chunk.text); + textOffset += chunk.text.length; + } + continue; + } + + if (chunk.type === "localImage") { + attachments.push({ + type: "localImage", + path: chunk.path, + name: getFileNameFromPath(chunk.path), + sizeBytes: 0, + }); + continue; + } + + if (chunk.type === "localFile") { + attachments.push({ + type: "localFile", + path: chunk.path, + name: chunk.name ?? getFileNameFromPath(chunk.path), + sizeBytes: chunk.sizeBytes ?? 0, + ...(chunk.mimeType ? { mimeType: chunk.mimeType } : {}), + }); + } + } + + return { + text: textSegments.join("\n\n"), + mentions, + attachments, + }; +} + +export function getProjectStoredPromptAttachmentPaths( + attachments: readonly PromptDraftAttachment[], +): string[] { + return [ + ...new Set( + attachments.flatMap((attachment) => { + const path = attachment.path; + const isRuntimeReadable = + /^[\\/]/u.test(path) || + /^[a-zA-Z]:[\\/]/u.test(path) || + /^[a-zA-Z][a-zA-Z0-9+.-]*:/u.test(path); + return isRuntimeReadable ? [] : [path]; + }), + ), + ]; +} diff --git a/packages/client-core/src/prompt/thread-handoff-request.ts b/packages/client-core/src/prompt/thread-handoff-request.ts new file mode 100644 index 0000000000..696daf4fa3 --- /dev/null +++ b/packages/client-core/src/prompt/thread-handoff-request.ts @@ -0,0 +1,90 @@ +import type { PromptTextMention } from "@bb/domain"; +import type { PromptDraftState } from "./prompt-draft.js"; + +export const THREAD_HANDOFF_CREATE_SEED_LOCATION_STATE_KEY = + "threadHandoffCreateSeed"; + +export interface ThreadHandoffCreateSeed { + environmentId: string | null; + projectId: string; + sourceThreadId: string; + sourceThreadTitle: string; +} + +export interface ThreadHandoffLocationState { + focusPrompt: true; + reuseEnvironmentId?: string; + [THREAD_HANDOFF_CREATE_SEED_LOCATION_STATE_KEY]: ThreadHandoffCreateSeed; +} + +export function buildThreadHandoffLocationState( + seed: ThreadHandoffCreateSeed, +): ThreadHandoffLocationState { + return { + focusPrompt: true, + ...(seed.environmentId !== null + ? { reuseEnvironmentId: seed.environmentId } + : {}), + [THREAD_HANDOFF_CREATE_SEED_LOCATION_STATE_KEY]: seed, + }; +} + +export function readThreadHandoffCreateSeedFromLocationState( + state: unknown, +): ThreadHandoffCreateSeed | null { + if (!state || typeof state !== "object") return null; + const candidate = (state as Record)[ + THREAD_HANDOFF_CREATE_SEED_LOCATION_STATE_KEY + ]; + if (!candidate || typeof candidate !== "object") return null; + const value = candidate as Record; + if ( + typeof value.projectId !== "string" || + value.projectId.length === 0 || + typeof value.sourceThreadId !== "string" || + value.sourceThreadId.length === 0 || + typeof value.sourceThreadTitle !== "string" || + value.sourceThreadTitle.trim().length === 0 + ) { + return null; + } + if ( + value.environmentId !== undefined && + value.environmentId !== null && + typeof value.environmentId !== "string" + ) { + return null; + } + + const environmentId = + typeof value.environmentId === "string" && value.environmentId.length > 0 + ? value.environmentId + : null; + + return { + environmentId, + projectId: value.projectId, + sourceThreadId: value.sourceThreadId, + sourceThreadTitle: value.sourceThreadTitle.trim(), + }; +} + +export function buildThreadHandoffPromptDraft( + seed: ThreadHandoffCreateSeed, +): PromptDraftState { + const prefix = "Continue from "; + const mentionText = `@thread:${seed.sourceThreadId}`; + const text = `${prefix}${mentionText}`; + const mention: PromptTextMention = { + start: prefix.length, + end: prefix.length + mentionText.length, + resource: { + kind: "thread", + projectId: seed.projectId, + threadId: seed.sourceThreadId, + label: seed.sourceThreadTitle, + }, + }; + + return { text, mentions: [mention], attachments: [] }; +} diff --git a/packages/client-core/src/prompt/threadDetailPromptSubmission.ts b/packages/client-core/src/prompt/threadDetailPromptSubmission.ts new file mode 100644 index 0000000000..1384115c6f --- /dev/null +++ b/packages/client-core/src/prompt/threadDetailPromptSubmission.ts @@ -0,0 +1,311 @@ +import type { + PermissionMode, + PromptInput, + ReasoningLevel, + ServiceTier, + ThreadRuntimeDisplayStatus, +} from "@bb/domain"; +import type { + CreateQueuedMessageRequest, + ExistingThreadExecutionInputSources, + SendMessageRequest, +} from "@bb/server-contract"; +import type { FollowUpSubmitMode } from "./follow-up-submit-mode.js"; + +/** `POST /threads/:id/messages` body plus the thread id it targets. */ +export interface SendMessageMutationRequest extends SendMessageRequest { + id: string; +} + +export interface CreateQueuedFollowUpRequest extends CreateQueuedMessageRequest { + id: string; +} + +export interface SendQueuedMessageByIdRequest { + id: string; + mode: "auto"; + queuedMessageId: string; +} + +export interface ThreadExecutionSelection { + model: string; + permissionMode: PermissionMode; + reasoningLevel: ReasoningLevel; + serviceTier: ServiceTier | undefined; + supportsServiceTier: boolean; + executionInputSources: ExistingThreadExecutionInputSources; +} + +export type FollowUpExecutionSelection = ThreadExecutionSelection | null; + +interface SharedThreadExecutionRequestFields { + model?: string; + permissionMode?: PermissionMode; + reasoningLevel?: ReasoningLevel; + serviceTier?: ServiceTier; + executionInputSources?: ExistingThreadExecutionInputSources; +} + +interface BaseFollowUpRequestArgs { + input: PromptInput[]; + threadId: string; +} + +export interface BuildAutoFollowUpRequestArgs extends BaseFollowUpRequestArgs { + execution: FollowUpExecutionSelection; +} + +export interface BuildCreateQueuedFollowUpRequestArgs extends BaseFollowUpRequestArgs { + execution: FollowUpExecutionSelection; +} + +export interface BuildSendQueuedMessageByIdRequestArgs { + queuedMessageId: string; + threadId: string; +} + +export interface BuildFollowUpShortcutRequestArgs extends BaseFollowUpRequestArgs { + queuedMessages: readonly QueuedMessageForSend[]; +} + +export interface CanSubmitFollowUpShortcutArgs { + hasPromptDraftInput: boolean; + isFollowUpSubmitting: boolean; + isQueueMutationPending: boolean; + queuedMessageCount: number; + runtimeDisplayStatus: ThreadRuntimeDisplayStatus; + submitModeKind: FollowUpSubmitMode["kind"]; +} + +export interface BuildFollowUpSubmitModeArgs { + hasPendingInteraction: boolean; + isDefaultExecutionOptionsLoading: boolean; + isPendingInteractionsInitialLoading: boolean; + isStopRequested: boolean; + onStop: () => void; + runtimeDisplayStatus: ThreadRuntimeDisplayStatus; +} + +export interface BuildSideChatSubmitModeArgs { + childThreadId: string | null; + isDefaultExecutionOptionsLoading: boolean; + isStopRequested: boolean; + onStop: () => void; + runtimeDisplayStatus: ThreadRuntimeDisplayStatus; +} + +export interface ResolveDefaultExecutionOptionsStateArgs { + hasConcreteDefaultExecutionOptions: boolean; + hasResolvedDefaultExecutionOptions: boolean; + isError: boolean; +} + +export interface QueuedMessageForSend { + id: string; +} + +export type FollowUpShortcutRequest = + | { kind: "draft"; request: SendMessageMutationRequest } + | { kind: "queued"; request: SendQueuedMessageByIdRequest }; + +export type DefaultExecutionOptionsState = + | "available" + | "loading" + | "unavailable"; + +export function shouldQueueFollowUpMessage( + displayStatus: ThreadRuntimeDisplayStatus, +): boolean { + return ( + displayStatus === "active" || + displayStatus === "host-reconnecting" || + displayStatus === "provisioning" || + displayStatus === "starting" || + displayStatus === "waiting-for-host" + ); +} + +export function buildFollowUpSubmitMode({ + hasPendingInteraction, + isDefaultExecutionOptionsLoading, + isPendingInteractionsInitialLoading, + isStopRequested, + onStop, + runtimeDisplayStatus, +}: BuildFollowUpSubmitModeArgs): FollowUpSubmitMode { + if (isStopRequested) { + return { kind: "blocked", reason: "stopping" }; + } + if (isPendingInteractionsInitialLoading) { + return { kind: "blocked", reason: "loading-pending-interactions" }; + } + if (hasPendingInteraction) { + return { kind: "blocked", reason: "pending-interaction" }; + } + if (shouldQueueFollowUpMessage(runtimeDisplayStatus)) { + return { kind: "queue", onStop }; + } + if (isDefaultExecutionOptionsLoading) { + return { kind: "blocked", reason: "loading-execution-options" }; + } + return { kind: "ready" }; +} + +export function buildSideChatSubmitMode({ + childThreadId, + isDefaultExecutionOptionsLoading, + isStopRequested, + onStop, + runtimeDisplayStatus, +}: BuildSideChatSubmitModeArgs): FollowUpSubmitMode { + if (childThreadId === null) { + return isDefaultExecutionOptionsLoading + ? { kind: "blocked", reason: "loading-execution-options" } + : { kind: "ready" }; + } + return buildFollowUpSubmitMode({ + hasPendingInteraction: false, + isDefaultExecutionOptionsLoading, + isPendingInteractionsInitialLoading: false, + isStopRequested, + onStop, + runtimeDisplayStatus, + }); +} + +export function canSubmitFollowUpShortcut({ + hasPromptDraftInput, + isFollowUpSubmitting, + isQueueMutationPending, + queuedMessageCount, + runtimeDisplayStatus, + submitModeKind, +}: CanSubmitFollowUpShortcutArgs): boolean { + return ( + runtimeDisplayStatus === "active" && + submitModeKind === "queue" && + !isFollowUpSubmitting && + !isQueueMutationPending && + (queuedMessageCount > 0 || hasPromptDraftInput) + ); +} + +export function resolveDefaultExecutionOptionsState({ + hasConcreteDefaultExecutionOptions, + hasResolvedDefaultExecutionOptions, + isError, +}: ResolveDefaultExecutionOptionsStateArgs): DefaultExecutionOptionsState { + if (hasConcreteDefaultExecutionOptions) { + return "available"; + } + if (hasResolvedDefaultExecutionOptions || isError) { + return "unavailable"; + } + return "loading"; +} + +export function buildAutoFollowUpRequest({ + execution, + input, + threadId, +}: BuildAutoFollowUpRequestArgs): SendMessageMutationRequest | null { + if (input.length === 0) { + return null; + } + + return { + id: threadId, + input, + mode: "queue-if-active", + ...buildSharedThreadExecutionRequestFields(execution), + }; +} + +function buildSteerFollowUpRequest({ + input, + threadId, +}: BaseFollowUpRequestArgs): SendMessageMutationRequest | null { + if (input.length === 0) { + return null; + } + + return { + id: threadId, + input, + mode: "steer-if-active", + }; +} + +export function buildCreateQueuedFollowUpRequest({ + execution, + input, + threadId, +}: BuildCreateQueuedFollowUpRequestArgs): CreateQueuedFollowUpRequest | null { + if (input.length === 0) { + return null; + } + + return { + id: threadId, + input, + ...buildSharedThreadExecutionRequestFields(execution), + }; +} + +export function buildSendQueuedMessageByIdRequest({ + queuedMessageId, + threadId, +}: BuildSendQueuedMessageByIdRequestArgs): SendQueuedMessageByIdRequest { + return { + id: threadId, + mode: "auto", + queuedMessageId, + }; +} + +/** + * Cmd+Enter on an active follow-up composer sends current draft input as an + * explicit steer. If the composer is empty, it sends only the current queue + * head through the same auto path as the queued-card "Send now" action. + */ +export function buildFollowUpShortcutRequest({ + input, + queuedMessages, + threadId, +}: BuildFollowUpShortcutRequestArgs): FollowUpShortcutRequest | null { + const draftRequest = buildSteerFollowUpRequest({ input, threadId }); + if (draftRequest) { + return { kind: "draft", request: draftRequest }; + } + + const nextQueuedMessage = queuedMessages[0]; + if (!nextQueuedMessage) { + return null; + } + + return { + kind: "queued", + request: buildSendQueuedMessageByIdRequest({ + queuedMessageId: nextQueuedMessage.id, + threadId, + }), + }; +} + +function buildSharedThreadExecutionRequestFields( + execution: FollowUpExecutionSelection, +): SharedThreadExecutionRequestFields { + if (execution === null) { + return {}; + } + + return { + model: execution.model, + ...(execution.supportsServiceTier && execution.serviceTier + ? { serviceTier: execution.serviceTier } + : {}), + reasoningLevel: execution.reasoningLevel, + permissionMode: execution.permissionMode, + executionInputSources: execution.executionInputSources, + }; +} diff --git a/packages/client-core/src/prompt/threadQueuedMessages.ts b/packages/client-core/src/prompt/threadQueuedMessages.ts new file mode 100644 index 0000000000..73ac7b9d4d --- /dev/null +++ b/packages/client-core/src/prompt/threadQueuedMessages.ts @@ -0,0 +1,91 @@ +import { type PromptInput } from "@bb/domain"; +import { fileNameFromPath } from "@bb/thread-view"; +import { promptInputToDraft, type PromptDraftState } from "./prompt-draft.js"; + +const QUEUED_MESSAGE_PREVIEW_MAX_CHARS = 140; + +interface FormatQueuedMessagePreviewOptions { + truncate?: boolean; +} + +function visibleQueuedMessageInput( + input: readonly PromptInput[], +): PromptInput[] { + return input.filter((chunk) => chunk.visibility !== "agent-only"); +} + +function getAttachmentNameFromPath(path: string): string { + const trimmedPath = path.trim(); + if (trimmedPath.length === 0) return "Attachment"; + return fileNameFromPath(trimmedPath); +} + +export function countQueuedMessageAttachments( + input: readonly PromptInput[], +): number { + let count = 0; + for (const chunk of visibleQueuedMessageInput(input)) { + if (chunk.type === "localImage" || chunk.type === "localFile") { + count += 1; + } + } + return count; +} + +export function getQueuedMessageVisibleText( + input: readonly PromptInput[], +): string { + return visibleQueuedMessageInput(input) + .filter( + (chunk): chunk is Extract => + chunk.type === "text", + ) + .map((chunk) => chunk.text.trim()) + .filter((chunk) => chunk.length > 0) + .join("\n\n"); +} + +export function formatQueuedMessagePreview( + input: readonly PromptInput[], + options: FormatQueuedMessagePreviewOptions = {}, +): string { + const visibleInput = visibleQueuedMessageInput(input); + const text = getQueuedMessageVisibleText(visibleInput); + const trimmedText = text.replace(/\s+/g, " ").trim(); + if (trimmedText.length > 0) { + if ( + options.truncate === false || + trimmedText.length <= QUEUED_MESSAGE_PREVIEW_MAX_CHARS + ) { + return trimmedText; + } + return `${trimmedText.slice(0, QUEUED_MESSAGE_PREVIEW_MAX_CHARS - 1)}...`; + } + + const attachmentCount = countQueuedMessageAttachments(visibleInput); + if (attachmentCount === 1) { + const firstAttachment = visibleInput.find( + (chunk) => chunk.type === "localImage" || chunk.type === "localFile", + ); + if (firstAttachment) { + if (firstAttachment.type === "localFile" && firstAttachment.name) { + return `Attachment only (${firstAttachment.name})`; + } + return `Attachment only (${getAttachmentNameFromPath( + firstAttachment.path, + )})`; + } + return "Attachment only (1 file)"; + } + if (attachmentCount > 1) { + return `Attachment only (${attachmentCount} files)`; + } + + return "(empty message)"; +} + +export function queuedInputToDraft( + input: readonly PromptInput[], +): PromptDraftState { + return promptInputToDraft(visibleQueuedMessageInput(input)); +} diff --git a/packages/client-core/src/routes/route-paths.ts b/packages/client-core/src/routes/route-paths.ts new file mode 100644 index 0000000000..584f2da51f --- /dev/null +++ b/packages/client-core/src/routes/route-paths.ts @@ -0,0 +1,273 @@ +import { PERSONAL_PROJECT_ID } from "@bb/domain"; + +export const APP_ROOT_ROUTE_PATH = "/"; +export const AUTH_CALLBACK_ROUTE_PATH = "/auth/callback"; +export const SETTINGS_ROUTE_PATH = "/settings"; +// Settings buckets (general, files, …) plus legacy plugin routes that redirect +// to the canonical Extensions → Plugins surfaces. The static "plugins" segment must +// win over :section so those old deep links resolve before redirecting. +export const SETTINGS_SECTION_ROUTE_PATH = "/settings/:section"; +export const SETTINGS_PLUGINS_ROUTE_PATH = "/settings/plugins"; +export const SETTINGS_PLUGIN_ROUTE_PATH = "/settings/plugins/:pluginId"; +export const SETTINGS_PROVIDER_ROUTE_PATH = "/settings/providers/:providerId"; +// Per-machine detail page. The static "machines" segment sits above the +// :section route, which has no splat and so never matches this two-segment path. +export const SETTINGS_MACHINE_ROUTE_PATH = "/settings/machines/:hostId"; +export const TOOLS_ROUTE_PATH = "/extensions"; +export const TOOLS_SKILLS_ROUTE_PATH = "/extensions/skills"; +export const TOOLS_SKILL_DETAIL_ROUTE_PATH = + "/extensions/skills/library/:skillId"; +export const LEGACY_TOOLS_SKILL_DETAIL_ROUTE_PATH = + "/extensions/skills/installed/:skillId"; +export const TOOLS_REGISTRY_SKILLS_ROUTE_PATH = "/extensions/skills/registry"; +export const TOOLS_REGISTRY_SKILL_DETAIL_ROUTE_PATH = + "/extensions/skills/registry/:registrySkillId"; +export const TOOLS_PLUGINS_ROUTE_PATH = "/extensions/plugins"; +export const TOOLS_PLUGIN_BROWSE_ROUTE_PATH = "/extensions/plugins/browse"; +export const TOOLS_PLUGIN_DETAIL_ROUTE_PATH = "/extensions/plugins/:pluginId"; +// The pre-rename Extensions prefix. Every /tools URL redirects to the same +// path under /extensions, so old deep links keep working. +export const LEGACY_TOOLS_PREFIX_ROUTE_PATH = "/tools"; +export const LEGACY_TOOLS_SPLAT_ROUTE_PATH = "/tools/*"; +export const LEGACY_TOOLS_AUTOMATIONS_ROUTE_PATH = "/tools/automations"; +export const LEGACY_TOOLS_AUTOMATION_BROWSE_ROUTE_PATH = + "/tools/automations/browse"; +export const LEGACY_TOOLS_AUTOMATION_DETAIL_ROUTE_PATH = + "/tools/automations/:projectId/:automationId"; +export const LEGACY_TOOLS_AUTOMATION_EDIT_ROUTE_PATH = + "/tools/automations/:projectId/:automationId/edit"; +export const LEGACY_SKILLS_ROUTE_PATH = "/skills"; +export const LEGACY_AUTOMATIONS_ROUTE_PATH = "/automations"; +export const LEGACY_AUTOMATION_DETAIL_ROUTE_PATH = + "/automations/:projectId/:automationId"; +export const AUTOMATIONS_PLUGIN_ID = "automations"; +export const AUTOMATIONS_PLUGIN_PANEL_PATH = "automations"; +export const AUTOMATIONS_ROUTE_PATH = "/plugins/automations/automations"; +export const AUTOMATIONS_BROWSE_ROUTE_PATH = + "/plugins/automations/automations/browse"; +export const AUTOMATION_DETAIL_ROUTE_PATH = + "/plugins/automations/automations/:projectId/:automationId"; +export const AUTOMATION_EDIT_ROUTE_PATH = + "/plugins/automations/automations/:projectId/:automationId/edit"; +export const SKILLS_ROUTE_PATH = TOOLS_SKILLS_ROUTE_PATH; +export const ROOT_COMPOSE_ROUTE_PATH = APP_ROOT_ROUTE_PATH; +export const LEGACY_PROJECT_COMPOSE_ROUTE_PATH = "/projects/:projectId"; +export const PROJECTLESS_ARCHIVED_ROUTE_PATH = "/archived"; +export const PROJECTLESS_THREAD_DETAIL_ROUTE_PATH = "/threads/:threadId"; +export const PROJECT_SETTINGS_ROUTE_PATH = "/projects/:projectId/settings"; +export const PROJECT_ARCHIVED_ROUTE_PATH = "/projects/:projectId/archived"; +export const THREAD_DETAIL_ROUTE_PATH = + "/projects/:projectId/threads/:threadId"; +// Trailing splat: the remainder is the panel's `subPath` (empty at the root). +export const PLUGIN_PANEL_ROUTE_PATH = "/plugins/:pluginId/:panelPath/*"; + +export interface ThreadRoutePathArgs { + projectId: string; + threadId: string; +} + +export function isProjectlessProjectId( + projectId: string | null | undefined, +): boolean { + return projectId === PERSONAL_PROJECT_ID; +} + +export function getRootComposeRoutePath(): string { + return ROOT_COMPOSE_ROUTE_PATH; +} + +export function getLegacyProjectComposeRoutePath(projectId: string): string { + return `/projects/${projectId}`; +} + +// Opens a project's compose view. The personal project has no `/projects/:id` +// surface — its compose view is the app root — so it routes there instead. +export function getProjectComposeRoutePath(projectId: string): string { + return isProjectlessProjectId(projectId) + ? getRootComposeRoutePath() + : getLegacyProjectComposeRoutePath(projectId); +} + +export function getSettingsRoutePath(section?: string): string { + return section === undefined + ? SETTINGS_ROUTE_PATH + : `/settings/${encodeURIComponent(section)}`; +} + +export function getSettingsProviderRoutePath(providerId: string): string { + return `/settings/providers/${encodeURIComponent(providerId)}`; +} + +export function getSettingsMachineRoutePath(hostId: string): string { + return `/settings/machines/${encodeURIComponent(hostId)}`; +} + +export function getSkillsRoutePath(): string { + return SKILLS_ROUTE_PATH; +} + +export function getRegistrySkillsRoutePath(): string { + return TOOLS_REGISTRY_SKILLS_ROUTE_PATH; +} + +export interface SkillDetailRoutePathArgs { + skillId: string; +} + +export function getSkillDetailRoutePath({ + skillId, +}: SkillDetailRoutePathArgs): string { + return `${TOOLS_SKILLS_ROUTE_PATH}/library/${encodeURIComponent(skillId)}`; +} + +export interface RegistrySkillDetailRoutePathArgs { + registrySkillId: string; +} + +export function getRegistrySkillDetailRoutePath({ + registrySkillId, +}: RegistrySkillDetailRoutePathArgs): string { + return `${TOOLS_SKILLS_ROUTE_PATH}/registry/${encodeURIComponent( + registrySkillId, + )}`; +} + +export function getPluginsRoutePath(): string { + return TOOLS_PLUGINS_ROUTE_PATH; +} + +export interface PluginDetailRoutePathArgs { + pluginId: string; + view?: "installed"; +} + +export function getPluginDetailRoutePath({ + pluginId, + view, +}: PluginDetailRoutePathArgs): string { + const path = `${TOOLS_PLUGINS_ROUTE_PATH}/${encodeURIComponent(pluginId)}`; + return view === "installed" ? `${path}?view=installed` : path; +} + +/** + * A plugin's configuration lives on the Settings page; the Extensions detail + * page links here instead of hosting the form. + */ +export function getPluginConfigurationRoutePath( + args: PluginDetailRoutePathArgs, +): string { + return `/settings/plugins/${encodeURIComponent(args.pluginId)}`; +} + +export function getAutomationsRoutePath(): string { + return AUTOMATIONS_ROUTE_PATH; +} + +export interface AutomationDetailRoutePathArgs { + projectId: string; + automationId: string; +} + +export function getAutomationDetailRoutePath({ + projectId, + automationId, +}: AutomationDetailRoutePathArgs): string { + return `${AUTOMATIONS_ROUTE_PATH}/${encodeURIComponent( + projectId, + )}/${encodeURIComponent(automationId)}`; +} + +export function getAutomationEditRoutePath( + args: AutomationDetailRoutePathArgs, +): string { + return `${getAutomationDetailRoutePath(args)}/edit`; +} + +export function getProjectSettingsRoutePath(projectId: string): string { + return `/projects/${projectId}/settings`; +} + +export interface PluginPanelRoutePathArgs { + pluginId: string; + /** The nav panel's registered `path` segment (validated: [a-zA-Z0-9_-]+). */ + path: string; + /** Location inside the panel; segments are encoded, slashes preserved. */ + subPath?: string; +} + +export function getPluginPanelRoutePath({ + pluginId, + path, + subPath, +}: PluginPanelRoutePathArgs): string { + const root = `/plugins/${encodeURIComponent(pluginId)}/${encodeURIComponent(path)}`; + if (subPath === undefined || subPath === "") { + return root; + } + const encoded = subPath + .split("/") + .filter((segment) => segment.length > 0) + .map((segment) => encodeURIComponent(segment)) + .join("/"); + return encoded.length > 0 ? `${root}/${encoded}` : root; +} + +export function getThreadRoutePath(args: ThreadRoutePathArgs): string { + return isProjectlessProjectId(args.projectId) + ? `/threads/${args.threadId}` + : `/projects/${args.projectId}/threads/${args.threadId}`; +} + +const baseRoutePatterns: readonly string[] = [ + APP_ROOT_ROUTE_PATH, + AUTH_CALLBACK_ROUTE_PATH, + SETTINGS_ROUTE_PATH, + SETTINGS_SECTION_ROUTE_PATH, + SETTINGS_PLUGINS_ROUTE_PATH, + SETTINGS_PLUGIN_ROUTE_PATH, + SETTINGS_PROVIDER_ROUTE_PATH, + TOOLS_ROUTE_PATH, + TOOLS_SKILLS_ROUTE_PATH, + TOOLS_SKILL_DETAIL_ROUTE_PATH, + LEGACY_TOOLS_SKILL_DETAIL_ROUTE_PATH, + TOOLS_REGISTRY_SKILLS_ROUTE_PATH, + TOOLS_REGISTRY_SKILL_DETAIL_ROUTE_PATH, + TOOLS_PLUGINS_ROUTE_PATH, + TOOLS_PLUGIN_BROWSE_ROUTE_PATH, + TOOLS_PLUGIN_DETAIL_ROUTE_PATH, + LEGACY_TOOLS_PREFIX_ROUTE_PATH, + LEGACY_TOOLS_SPLAT_ROUTE_PATH, + LEGACY_TOOLS_AUTOMATIONS_ROUTE_PATH, + LEGACY_TOOLS_AUTOMATION_BROWSE_ROUTE_PATH, + LEGACY_TOOLS_AUTOMATION_DETAIL_ROUTE_PATH, + LEGACY_TOOLS_AUTOMATION_EDIT_ROUTE_PATH, + LEGACY_SKILLS_ROUTE_PATH, + LEGACY_AUTOMATIONS_ROUTE_PATH, + LEGACY_AUTOMATION_DETAIL_ROUTE_PATH, + AUTOMATIONS_ROUTE_PATH, + AUTOMATIONS_BROWSE_ROUTE_PATH, + AUTOMATION_DETAIL_ROUTE_PATH, + AUTOMATION_EDIT_ROUTE_PATH, + LEGACY_PROJECT_COMPOSE_ROUTE_PATH, + PROJECTLESS_ARCHIVED_ROUTE_PATH, + PROJECT_SETTINGS_ROUTE_PATH, + PROJECT_ARCHIVED_ROUTE_PATH, + PROJECTLESS_THREAD_DETAIL_ROUTE_PATH, + THREAD_DETAIL_ROUTE_PATH, + PLUGIN_PANEL_ROUTE_PATH, +]; + +export const ROUTE_PATTERNS = baseRoutePatterns; + +/** Drops the `?query` and `#hash` from a path so route matching sees only the pathname. */ +export function stripRoutePathSuffix(path: string): string { + const queryIndex = path.indexOf("?"); + const hashIndex = path.indexOf("#"); + const suffixIndex = + queryIndex === -1 + ? hashIndex + : hashIndex === -1 + ? queryIndex + : Math.min(queryIndex, hashIndex); + return suffixIndex === -1 ? path : path.slice(0, suffixIndex); +} diff --git a/packages/client-core/src/sidebar/machineThreadGroups.ts b/packages/client-core/src/sidebar/machineThreadGroups.ts new file mode 100644 index 0000000000..9d12d84820 --- /dev/null +++ b/packages/client-core/src/sidebar/machineThreadGroups.ts @@ -0,0 +1,68 @@ +import type { Host, ThreadListEntry } from "@bb/domain"; + +// Group key for threads whose environment has no host (plain chats). Host ids +// are prefixed (e.g. "host_…"), so the sentinel cannot collide with one. +export const NO_MACHINE_GROUP_KEY = "no-machine"; + +export interface MachineThreadGroup { + /** Host id, or {@link NO_MACHINE_GROUP_KEY}. */ + key: string; + label: string; + threads: ThreadListEntry[]; +} + +/** + * Buckets threads by the host their environment runs on, for the sidebar's + * "By machine" view. Groups follow server host order; hosts the server no + * longer lists keep a stable id-ordered section; machineless threads land in + * a trailing "No machine" group. Machines without threads get no group. + */ +export function buildMachineThreadGroups( + threads: readonly ThreadListEntry[], + hosts: readonly Host[], +): MachineThreadGroup[] { + const threadsByKey = new Map(); + for (const thread of threads) { + const key = thread.environmentHostId ?? NO_MACHINE_GROUP_KEY; + const existing = threadsByKey.get(key); + if (existing) { + existing.push(thread); + } else { + threadsByKey.set(key, [thread]); + } + } + + const groups: MachineThreadGroup[] = []; + for (const host of hosts) { + const hostThreads = threadsByKey.get(host.id); + if (!hostThreads) { + continue; + } + threadsByKey.delete(host.id); + groups.push({ key: host.id, label: host.name, threads: hostThreads }); + } + + const noMachineThreads = threadsByKey.get(NO_MACHINE_GROUP_KEY); + threadsByKey.delete(NO_MACHINE_GROUP_KEY); + + const unknownHostIds = Array.from(threadsByKey.keys()).sort((left, right) => + left.localeCompare(right), + ); + for (const hostId of unknownHostIds) { + groups.push({ + key: hostId, + label: "Unknown machine", + threads: threadsByKey.get(hostId) ?? [], + }); + } + + if (noMachineThreads) { + groups.push({ + key: NO_MACHINE_GROUP_KEY, + label: "No machine", + threads: noMachineThreads, + }); + } + + return groups; +} diff --git a/packages/client-core/src/sidebar/neighbor-reorder.ts b/packages/client-core/src/sidebar/neighbor-reorder.ts new file mode 100644 index 0000000000..94a88d9765 --- /dev/null +++ b/packages/client-core/src/sidebar/neighbor-reorder.ts @@ -0,0 +1,116 @@ +export interface NeighborReorderItem { + id: string; +} + +export interface NeighborReorderRequest { + itemId: string; + nextItemId: string | null; + previousItemId: string | null; +} + +export interface BuildNeighborReorderRequestArgs< + Item extends NeighborReorderItem, +> { + activeId: string; + items: readonly Item[]; + overId: string; +} + +export interface ApplyNeighborReorderArgs { + items: readonly Item[]; + request: NeighborReorderRequest; +} + +interface MoveItemArgs { + fromIndex: number; + items: readonly Item[]; + toIndex: number; +} + +function moveItem({ + fromIndex, + items, + toIndex, +}: MoveItemArgs): Item[] { + const result = [...items]; + const movedItems = result.splice(fromIndex, 1); + const movedItem = movedItems[0]; + if (!movedItem) { + return result; + } + result.splice(toIndex, 0, movedItem); + return result; +} + +export function buildNeighborReorderRequest({ + activeId, + items, + overId, +}: BuildNeighborReorderRequestArgs): NeighborReorderRequest | null { + if (activeId === overId) { + return null; + } + + const oldIndex = items.findIndex((item) => item.id === activeId); + const newIndex = items.findIndex((item) => item.id === overId); + if (oldIndex === -1 || newIndex === -1) { + return null; + } + + const reorderedItems = moveItem({ + items, + fromIndex: oldIndex, + toIndex: newIndex, + }); + const movedIndex = reorderedItems.findIndex((item) => item.id === activeId); + if (movedIndex === -1) { + return null; + } + + return { + itemId: activeId, + previousItemId: reorderedItems[movedIndex - 1]?.id ?? null, + nextItemId: reorderedItems[movedIndex + 1]?.id ?? null, + }; +} + +export function applyNeighborReorder({ + items, + request, +}: ApplyNeighborReorderArgs): Item[] { + const movedIndex = items.findIndex((item) => item.id === request.itemId); + if (movedIndex === -1) { + return [...items]; + } + + const movedItem = items[movedIndex]; + if (!movedItem) { + return [...items]; + } + const remainingItems = items.filter((item) => item.id !== request.itemId); + let insertIndex = 0; + + if (request.previousItemId !== null) { + const previousIndex = remainingItems.findIndex( + (item) => item.id === request.previousItemId, + ); + if (previousIndex === -1) { + return [...items]; + } + insertIndex = previousIndex + 1; + } else if (request.nextItemId !== null) { + const nextIndex = remainingItems.findIndex( + (item) => item.id === request.nextItemId, + ); + if (nextIndex === -1) { + return [...items]; + } + insertIndex = nextIndex; + } + + return [ + ...remainingItems.slice(0, insertIndex), + movedItem, + ...remainingItems.slice(insertIndex), + ]; +} diff --git a/packages/client-core/src/sidebar/pinnedSidebarThreads.ts b/packages/client-core/src/sidebar/pinnedSidebarThreads.ts new file mode 100644 index 0000000000..49dd2e1885 --- /dev/null +++ b/packages/client-core/src/sidebar/pinnedSidebarThreads.ts @@ -0,0 +1,144 @@ +import type { ThreadListEntry } from "@bb/domain"; +import { compareCodepoint } from "../codepoint-compare.js"; +import { + buildProjectThreadGroups, + compareStandardThreads, + type ProjectThreadItem, + type ProjectThreadNode, +} from "./projectThreadGroups.js"; + +interface PinnedSidebarState { + effectivePinnedThreadIds: Set; + rootNodes: ProjectThreadNode[]; +} + +interface BuildPinnedSidebarStateArgs { + draftThreadIds?: ReadonlySet; + threads: readonly ThreadListEntry[]; +} + +function compareByPinnedFallback( + left: ThreadListEntry, + right: ThreadListEntry, +): number { + const pinnedAtDelta = (right.pinnedAt ?? 0) - (left.pinnedAt ?? 0); + if (pinnedAtDelta !== 0) { + return pinnedAtDelta; + } + + const createdAtDelta = right.createdAt - left.createdAt; + if (createdAtDelta !== 0) { + return createdAtDelta; + } + + return compareCodepoint(left.id, right.id); +} + +function comparePinnedRoots( + left: ThreadListEntry, + right: ThreadListEntry, +): number { + if (left.pinSortKey !== null && right.pinSortKey !== null) { + const pinSortKeyDelta = compareCodepoint(left.pinSortKey, right.pinSortKey); + if (pinSortKeyDelta !== 0) { + return pinSortKeyDelta; + } + } + + return compareByPinnedFallback(left, right); +} + +function addDescendantThreadIds({ + childrenByParentId, + effectivePinnedThreadIds, + parentThreadId, + visitedThreadIds, +}: AddDescendantThreadIdsArgs): void { + if (visitedThreadIds.has(parentThreadId)) return; + + visitedThreadIds.add(parentThreadId); + for (const child of childrenByParentId.get(parentThreadId) ?? []) { + effectivePinnedThreadIds.add(child.id); + addDescendantThreadIds({ + childrenByParentId, + effectivePinnedThreadIds, + parentThreadId: child.id, + visitedThreadIds, + }); + } +} + +interface AddDescendantThreadIdsArgs { + childrenByParentId: ReadonlyMap; + effectivePinnedThreadIds: Set; + parentThreadId: string; + visitedThreadIds: Set; +} + +function collectRootNodes( + items: readonly ProjectThreadItem[], +): ProjectThreadNode[] { + return items.flatMap((item) => { + switch (item.kind) { + case "thread": + return [item.node]; + case "environment": + return item.group.nodes; + case "section": + // Pinned flattens before folding, so sections never reach here; recurse + // to keep the function total. + return collectRootNodes(item.group.items); + } + }); +} + +export function buildPinnedSidebarState({ + draftThreadIds = new Set(), + threads, +}: BuildPinnedSidebarStateArgs): PinnedSidebarState { + const explicitlyPinnedThreads = threads.filter( + (thread) => thread.pinnedAt !== null, + ); + const childrenByParentId = new Map(); + + for (const thread of threads) { + if (thread.parentThreadId === null) continue; + + const children = childrenByParentId.get(thread.parentThreadId); + if (children) { + children.push(thread); + } else { + childrenByParentId.set(thread.parentThreadId, [thread]); + } + } + + const effectivePinnedThreadIds = new Set( + explicitlyPinnedThreads.map((thread) => thread.id), + ); + for (const thread of explicitlyPinnedThreads) { + addDescendantThreadIds({ + childrenByParentId, + effectivePinnedThreadIds, + parentThreadId: thread.id, + visitedThreadIds: new Set(), + }); + } + + const effectivePinnedThreads = threads.filter((thread) => + effectivePinnedThreadIds.has(thread.id), + ); + const projectItems = buildProjectThreadGroups( + effectivePinnedThreads, + compareStandardThreads, + draftThreadIds, + ); + const rootNodes = collectRootNodes(projectItems); + rootNodes.sort((left, right) => + comparePinnedRoots(left.thread, right.thread), + ); + + return { + effectivePinnedThreadIds, + rootNodes, + }; +} diff --git a/packages/client-core/src/sidebar/projectThreadGroups.ts b/packages/client-core/src/sidebar/projectThreadGroups.ts new file mode 100644 index 0000000000..6c7496aba6 --- /dev/null +++ b/packages/client-core/src/sidebar/projectThreadGroups.ts @@ -0,0 +1,855 @@ +import type { + EnvironmentWorkspaceDisplayKind, + ThreadListEntry, +} from "@bb/domain"; +import { compareCodepoint } from "../codepoint-compare.js"; +import { + getCollapsedChildActivity, + type CollapsedChildActivity, +} from "../thread/thread-activity.js"; +import { buildSectionKey } from "./sectionKeys.js"; + +interface ProjectThreadNodeStats { + childCount: number; + childActivity: CollapsedChildActivity; +} + +export interface ProjectThreadNode { + thread: ThreadListEntry; + children: ProjectThreadItem[]; + depth: number; + stats: ProjectThreadNodeStats; +} + +type EnvironmentThreadGroupNodes = [ + ProjectThreadNode, + ProjectThreadNode, + ...ProjectThreadNode[], +]; + +export interface EnvironmentThreadGroup { + environmentId: string; + nodes: EnvironmentThreadGroupNodes; + stats: ProjectThreadNodeStats; +} + +export interface SidebarSectionDefinition { + id: string; + name: string; +} + +// A flat section node backed by a durable DB section row. +export interface SidebarSectionGroup { + id: string; + key: string; + name: string; + items: ProjectThreadItem[]; + threadCount: number; + activity: CollapsedChildActivity; +} + +// A single render slot in a thread sibling list. Threads and env groups +// interleave by recency, so renderers iterate one ordered list rather than two +// parallel arrays. Sections join the same list only under Group by: Section. +export type ProjectThreadItem = + | { kind: "thread"; node: ProjectThreadNode } + | { kind: "environment"; group: EnvironmentThreadGroup } + | { kind: "section"; group: SidebarSectionGroup }; + +// Container-id sentinel for the global section section. It namespaces persisted +// collapse keys and dnd ids from other sidebar rows. +export const CHRONOLOGICAL_CONTAINER_ID = "chronological"; + +// Orders sibling threads. The default keeps active rows pinned to createdAt and +// inactive rows on attention recency; chronological mode can swap in a literal +// createdAt comparator instead. +type ThreadItemComparator = ( + left: ProjectThreadItem, + right: ProjectThreadItem, +) => number; + +export type ThreadComparator = (( + left: ThreadListEntry, + right: ThreadListEntry, +) => number) & { + compareItems?: ThreadItemComparator; +}; + +type WorktreeDisplayKind = "managed-worktree" | "unmanaged-worktree"; +type SidebarProjectThreadShape = Pick< + ThreadListEntry, + "originKind" | "visibility" +>; + +interface BuildThreadNodeArgs { + ancestorThreadIds: ReadonlySet; + childrenByParentId: ReadonlyMap; + compareThreads: ThreadComparator; + depth: number; + draftThreadIds: ReadonlySet; + groupEnvironmentThreads: boolean; + thread: ThreadListEntry; + visitedThreadIds: Set; +} + +interface BucketWorktreeEnvironmentGroupsResult { + environmentThreadGroups: EnvironmentThreadGroup[]; + looseNodes: ProjectThreadNode[]; +} + +function isWorktreeDisplayKind( + kind: EnvironmentWorkspaceDisplayKind, +): kind is WorktreeDisplayKind { + return kind === "managed-worktree" || kind === "unmanaged-worktree"; +} + +export function compareByCreatedAtDescending( + left: ThreadListEntry, + right: ThreadListEntry, +): number { + const createdAtDelta = right.createdAt - left.createdAt; + if (createdAtDelta !== 0) { + return createdAtDelta; + } + + return compareCodepoint(left.id, right.id); +} + +function compareByLatestAttentionAtDescending( + left: ThreadListEntry, + right: ThreadListEntry, +): number { + const latestAttentionAtDelta = + right.latestAttentionAt - left.latestAttentionAt; + if (latestAttentionAtDelta !== 0) { + return latestAttentionAtDelta; + } + + return compareByCreatedAtDescending(left, right); +} + +export function compareStandardThreads( + left: ThreadListEntry, + right: ThreadListEntry, +): number { + // Use durable thread.status for the active bucket, not ephemeral runtime + // display state. Active rows stream frequent updates, so pin their position + // to createdAt; inactive rows use attention recency so read/archive metadata + // updates do not reshuffle the sidebar. + const leftIsActive = left.status === "active"; + const rightIsActive = right.status === "active"; + + if (leftIsActive !== rightIsActive) { + return leftIsActive ? -1 : 1; + } + + if (leftIsActive) { + return compareByCreatedAtDescending(left, right); + } + + return compareByLatestAttentionAtDescending(left, right); +} + +function representativeThread(item: ProjectThreadItem): ThreadListEntry { + switch (item.kind) { + case "thread": + return item.node.thread; + case "environment": + return item.group.nodes[0].thread; + case "section": + // Sections never reach this pre-bucket comparator path; fall back to the + // first nested item's representative so the function stays total. + return representativeThread(item.group.items[0]); + } +} + +function compareProjectThreadItems( + left: ProjectThreadItem, + right: ProjectThreadItem, + compareThreads: ThreadComparator, +): number { + return compareThreads( + representativeThread(left), + representativeThread(right), + ); +} + +function getNodeAndDescendantThreads( + node: ProjectThreadNode, +): ThreadListEntry[] { + return [node.thread, ...getProjectThreadItemDescendants(node.children)]; +} + +export function getProjectThreadItemDescendants( + items: readonly ProjectThreadItem[], +): ThreadListEntry[] { + return items.flatMap((item) => { + switch (item.kind) { + case "thread": + return getNodeAndDescendantThreads(item.node); + case "environment": + return item.group.nodes.flatMap(getNodeAndDescendantThreads); + case "section": + return getProjectThreadItemDescendants(item.group.items); + } + }); +} + +function buildStatsForHiddenThreads( + threads: readonly ThreadListEntry[], + draftThreadIds: ReadonlySet, +): ProjectThreadNodeStats { + return { + childCount: threads.length, + childActivity: getCollapsedChildActivity(threads, draftThreadIds), + }; +} + +function buildEnvironmentThreadGroup( + environmentId: string, + nodes: EnvironmentThreadGroupNodes, + draftThreadIds: ReadonlySet, +): EnvironmentThreadGroup { + const hiddenThreads = nodes.flatMap(getNodeAndDescendantThreads); + return { + environmentId, + nodes, + stats: buildStatsForHiddenThreads(hiddenThreads, draftThreadIds), + }; +} + +function buildThreadItem(node: ProjectThreadNode): ProjectThreadItem { + return { kind: "thread", node }; +} + +function buildEnvironmentItem( + group: EnvironmentThreadGroup, +): ProjectThreadItem { + return { kind: "environment", group }; +} + +function buildSortedItems( + nodes: ProjectThreadNode[], + compareThreads: ThreadComparator, + groupEnvironmentThreads: boolean, + draftThreadIds: ReadonlySet, +): ProjectThreadItem[] { + if (!groupEnvironmentThreads) { + nodes.sort((left, right) => compareThreads(left.thread, right.thread)); + return nodes.map(buildThreadItem); + } + + const { environmentThreadGroups, looseNodes } = + bucketWorktreeEnvironmentGroups(nodes, compareThreads, draftThreadIds); + const items = [ + ...looseNodes.map(buildThreadItem), + ...environmentThreadGroups.map(buildEnvironmentItem), + ]; + items.sort((left, right) => + compareProjectThreadItems(left, right, compareThreads), + ); + return items; +} + +function buildThreadNode({ + ancestorThreadIds, + childrenByParentId, + compareThreads, + depth, + draftThreadIds, + groupEnvironmentThreads, + thread, + visitedThreadIds, +}: BuildThreadNodeArgs): ProjectThreadNode { + visitedThreadIds.add(thread.id); + const nextAncestorThreadIds = new Set(ancestorThreadIds); + nextAncestorThreadIds.add(thread.id); + const childNodes: ProjectThreadNode[] = []; + + for (const childThread of childrenByParentId.get(thread.id) ?? []) { + if (nextAncestorThreadIds.has(childThread.id)) continue; + if (visitedThreadIds.has(childThread.id)) continue; + + childNodes.push( + buildThreadNode({ + ancestorThreadIds: nextAncestorThreadIds, + childrenByParentId, + compareThreads, + depth: depth + 1, + draftThreadIds, + groupEnvironmentThreads, + thread: childThread, + visitedThreadIds, + }), + ); + } + + const children = buildSortedItems( + childNodes, + compareThreads, + groupEnvironmentThreads, + draftThreadIds, + ); + return { + thread, + children, + depth, + stats: buildStatsForHiddenThreads( + getProjectThreadItemDescendants(children), + draftThreadIds, + ), + }; +} + +function isRootThread( + thread: ThreadListEntry, + projectThreadIds: ReadonlySet, +): boolean { + return ( + thread.parentThreadId === null || + !projectThreadIds.has(thread.parentThreadId) + ); +} + +/** + * Resolve the project group that shows a thread in "By project" mode. A child + * follows its root ancestor, so a child from another project stays nested under + * its parent instead of appearing as a root in its own project. When the parent + * chain is not in the list (archived, hidden, or a cycle), the thread falls + * back to its own project. + * + * The returned resolver memoizes per thread, so a sidebar pass over every + * thread walks each ancestor chain once instead of once per descendant. + */ +export function createSidebarProjectIdResolver( + threadById: ReadonlyMap, +): (thread: ThreadListEntry) => string { + const sidebarProjectIdByThreadId = new Map(); + return (thread) => { + const cached = sidebarProjectIdByThreadId.get(thread.id); + if (cached !== undefined) { + return cached; + } + const chain: ThreadListEntry[] = [thread]; + const visitedThreadIds = new Set([thread.id]); + let current = thread; + let resolved: string | undefined; + while (current.parentThreadId !== null) { + const parent = threadById.get(current.parentThreadId); + if (parent === undefined || visitedThreadIds.has(parent.id)) { + break; + } + const parentResolved = sidebarProjectIdByThreadId.get(parent.id); + if (parentResolved !== undefined) { + resolved = parentResolved; + break; + } + visitedThreadIds.add(parent.id); + chain.push(parent); + current = parent; + } + const sidebarProjectId = resolved ?? current.projectId; + for (const member of chain) { + sidebarProjectIdByThreadId.set(member.id, sidebarProjectId); + } + return sidebarProjectId; + }; +} + +export function resolveSidebarProjectId( + thread: ThreadListEntry, + threadById: ReadonlyMap, +): string { + return createSidebarProjectIdResolver(threadById)(thread); +} + +export function buildProjectThreadGroups( + allProjectThreads: readonly ThreadListEntry[], + compareThreads: ThreadComparator = compareStandardThreads, + draftThreadIds: ReadonlySet = new Set(), +): ProjectThreadItem[] { + // Project sections group worktree siblings into synthetic environment rows. + return buildThreadTreeItems( + allProjectThreads, + compareThreads, + true, + draftThreadIds, + ); +} + +function buildThreadTreeItems( + allThreads: readonly ThreadListEntry[], + compareThreads: ThreadComparator, + groupEnvironmentThreads: boolean, + draftThreadIds: ReadonlySet, +): ProjectThreadItem[] { + const projectThreads = allThreads.filter(isSidebarProjectThread); + const projectThreadIds = new Set(projectThreads.map((thread) => thread.id)); + const childrenByParentId = new Map(); + + for (const thread of projectThreads) { + if (thread.parentThreadId === null) continue; + if (!projectThreadIds.has(thread.parentThreadId)) continue; + + const children = childrenByParentId.get(thread.parentThreadId); + if (children) { + children.push(thread); + } else { + childrenByParentId.set(thread.parentThreadId, [thread]); + } + } + + const visitedThreadIds = new Set(); + const rootNodes: ProjectThreadNode[] = []; + + for (const thread of projectThreads) { + if (!isRootThread(thread, projectThreadIds)) continue; + if (visitedThreadIds.has(thread.id)) continue; + + rootNodes.push( + buildThreadNode({ + ancestorThreadIds: new Set(), + childrenByParentId, + compareThreads, + depth: 0, + draftThreadIds, + groupEnvironmentThreads, + thread, + visitedThreadIds, + }), + ); + } + + // Cycles have no natural root. Render any remaining cycle member once at the + // project root and cut the back-edge when the walk reaches an ancestor. + for (const thread of projectThreads) { + if (visitedThreadIds.has(thread.id)) continue; + + rootNodes.push( + buildThreadNode({ + ancestorThreadIds: new Set(), + childrenByParentId, + compareThreads, + depth: 0, + draftThreadIds, + groupEnvironmentThreads, + thread, + visitedThreadIds, + }), + ); + } + + return buildSortedItems( + rootNodes, + compareThreads, + groupEnvironmentThreads, + draftThreadIds, + ); +} + +// Chronological Threads bucket: root threads are globally ordered by the +// chosen comparator and descendants stay nested under their parent. Worktree +// grouping stays off. Side chats are excluded to match buildProjectThreadGroups. +export function buildChronologicalThreadList( + allThreads: readonly ThreadListEntry[], + compareThreads: ThreadComparator = compareStandardThreads, + draftThreadIds: ReadonlySet = new Set(), +): ProjectThreadItem[] { + return buildThreadTreeItems( + allThreads, + compareThreads, + false, + draftThreadIds, + ); +} + +// The global Sections view uses the chronological root tree, then buckets those +// roots by their durable section id. Descendants stay nested under their parent. +export function buildSectionThreadList( + allThreads: readonly ThreadListEntry[], + compareThreads: ThreadComparator = compareStandardThreads, + sections: readonly SidebarSectionDefinition[] = [], + draftThreadIds: ReadonlySet = new Set(), +): ProjectThreadItem[] { + return bucketIntoSections( + buildChronologicalThreadList(allThreads, compareThreads, draftThreadIds), + CHRONOLOGICAL_CONTAINER_ID, + compareThreads, + sections, + draftThreadIds, + ); +} + +export function isSidebarProjectThread( + thread: SidebarProjectThreadShape, +): boolean { + return thread.visibility !== "hidden"; +} + +// Bucket nodes by shared worktree environmentId. A bucket only becomes a group +// when >=2 sibling nodes share the environment; solo threads stay loose so we +// don't render degenerate 1-thread groups. +function bucketWorktreeEnvironmentGroups( + nodes: ProjectThreadNode[], + compareThreads: ThreadComparator, + draftThreadIds: ReadonlySet, +): BucketWorktreeEnvironmentGroupsResult { + const nodesByEnvironmentId = new Map(); + for (const node of nodes) { + if (node.thread.environmentId === null) continue; + if (!isWorktreeDisplayKind(node.thread.environmentWorkspaceDisplayKind)) { + continue; + } + const bucket = nodesByEnvironmentId.get(node.thread.environmentId); + if (bucket) { + bucket.push(node); + } else { + nodesByEnvironmentId.set(node.thread.environmentId, [node]); + } + } + + const groupedEnvironmentIds = new Set(); + const environmentThreadGroups: EnvironmentThreadGroup[] = []; + for (const [environmentId, bucket] of nodesByEnvironmentId) { + if (!hasAtLeastTwoThreadNodes(bucket)) continue; + bucket.sort((left, right) => compareThreads(left.thread, right.thread)); + groupedEnvironmentIds.add(environmentId); + environmentThreadGroups.push( + buildEnvironmentThreadGroup(environmentId, bucket, draftThreadIds), + ); + } + + const looseNodes = nodes.filter( + (node) => + node.thread.environmentId === null || + !groupedEnvironmentIds.has(node.thread.environmentId), + ); + looseNodes.sort((left, right) => compareThreads(left.thread, right.thread)); + + return { environmentThreadGroups, looseNodes }; +} + +function hasAtLeastTwoThreadNodes( + nodes: ProjectThreadNode[], +): nodes is EnvironmentThreadGroupNodes { + return nodes.length >= 2; +} + +// The thread that orders an item among its siblings. +function getItemOrderingThread( + item: ProjectThreadItem, + compareThreads: ThreadComparator, +): ThreadListEntry | null { + switch (item.kind) { + case "thread": + return item.node.thread; + case "environment": + return item.group.nodes[0].thread; + case "section": { + const descendants = getProjectThreadItemDescendants(item.group.items); + if (descendants.length === 0) { + return null; + } + return descendants.reduce((first, thread) => + compareThreads(thread, first) < 0 ? thread : first, + ); + } + } +} + +export function getSidebarDndItemId(item: ProjectThreadItem): string { + switch (item.kind) { + case "thread": + return item.node.thread.id; + case "environment": + return item.group.nodes[0].thread.id; + case "section": + return item.group.key; + } +} + +// Orders sections first, then each block by the active comparator. +function orderSiblingItems( + items: readonly ProjectThreadItem[], + compareThreads: ThreadComparator, +): ProjectThreadItem[] { + const decorated = items.map((item) => ({ + item, + isSection: item.kind === "section", + })); + decorated.sort((left, right) => { + if (left.isSection !== right.isSection) { + return left.isSection ? -1 : 1; + } + return compareSiblingItems(left.item, right.item, compareThreads); + }); + return decorated.map((entry) => entry.item); +} + +function getItemFallbackSortLabel(item: ProjectThreadItem): string { + switch (item.kind) { + case "thread": + return item.node.thread.id; + case "environment": + return item.group.environmentId; + case "section": + return item.group.name; + } +} + +function compareSiblingItems( + left: ProjectThreadItem, + right: ProjectThreadItem, + compareThreads: ThreadComparator, +): number { + if (compareThreads.compareItems) { + return compareThreads.compareItems(left, right); + } + + const leftThread = getItemOrderingThread(left, compareThreads); + const rightThread = getItemOrderingThread(right, compareThreads); + if (leftThread && rightThread) { + return compareThreads(leftThread, rightThread); + } + if (leftThread || rightThread) { + return leftThread ? -1 : 1; + } + return compareCodepoint( + getItemFallbackSortLabel(left), + getItemFallbackSortLabel(right), + ); +} + +function buildSectionGroup( + containerId: string, + section: SidebarSectionDefinition, + items: ProjectThreadItem[], + draftThreadIds: ReadonlySet, +): SidebarSectionGroup { + const descendantThreads = getProjectThreadItemDescendants(items); + return { + id: section.id, + key: buildSectionKey(containerId, section.id), + name: section.name, + items, + threadCount: descendantThreads.length, + activity: getCollapsedChildActivity(descendantThreads, draftThreadIds), + }; +} + +// Fold a top-level item list into flat DB-backed sections plus loose items. +function bucketIntoSections( + items: readonly ProjectThreadItem[], + containerId: string, + compareThreads: ThreadComparator = compareStandardThreads, + sections: readonly SidebarSectionDefinition[] = [], + draftThreadIds: ReadonlySet = new Set(), +): ProjectThreadItem[] { + const sectionDefinitionsById = new Map(); + const orderedSections: SidebarSectionDefinition[] = []; + for (const section of sections) { + if (sectionDefinitionsById.has(section.id)) { + continue; + } + sectionDefinitionsById.set(section.id, section); + orderedSections.push(section); + } + + const itemsBySectionId = new Map(); + for (const section of orderedSections) { + itemsBySectionId.set(section.id, []); + } + const looseItems: ProjectThreadItem[] = []; + + for (const item of items) { + const orderingThread = getItemOrderingThread(item, compareThreads); + const sectionId = orderingThread?.sectionId; + if (!sectionId) { + looseItems.push(item); + continue; + } + + let sectionItems = itemsBySectionId.get(sectionId); + if (!sectionItems) { + const fallbackSection = { id: sectionId, name: "Section" }; + sectionDefinitionsById.set(sectionId, fallbackSection); + orderedSections.push(fallbackSection); + sectionItems = []; + itemsBySectionId.set(sectionId, sectionItems); + } + sectionItems.push(item); + } + + const sectionItemsByName = orderedSections.map( + (section): ProjectThreadItem => { + const children = orderSiblingItems( + itemsBySectionId.get(section.id) ?? [], + compareThreads, + ); + return { + kind: "section", + group: buildSectionGroup( + containerId, + section, + children, + draftThreadIds, + ), + }; + }, + ); + const sectionItems = compareThreads.compareItems + ? orderSiblingItems(sectionItemsByName, compareThreads) + : sectionItemsByName; + const orderedLooseItems = orderSiblingItems(looseItems, compareThreads); + return [...sectionItems, ...orderedLooseItems]; +} + +export interface ProjectThreadItemRowCountContext { + collapsedThreadIds: ReadonlySet; + collapsedEnvironmentIds: ReadonlySet; + collapsedSectionKeys: ReadonlySet; +} + +function countThreadNodeRows( + node: ProjectThreadNode, + context: ProjectThreadItemRowCountContext, +): number { + if ( + node.children.length === 0 || + context.collapsedThreadIds.has(node.thread.id) + ) { + return 1; + } + return node.children.reduce( + (total, child) => total + countProjectThreadItemRows(child, context), + 1, + ); +} + +/** + * Count of the rows an item renders under the current collapse state. Drives + * placeholder-height estimates in the windowed sidebar thread list; exactness + * is not required because measured heights replace the estimate once an item + * has been on screen. + */ +export function countProjectThreadItemRows( + item: ProjectThreadItem, + context: ProjectThreadItemRowCountContext, +): number { + switch (item.kind) { + case "thread": + return countThreadNodeRows(item.node, context); + case "environment": + if (context.collapsedEnvironmentIds.has(item.group.environmentId)) { + return 1; + } + return item.group.nodes.reduce( + (total, node) => total + countThreadNodeRows(node, context), + 1, + ); + case "section": + if (context.collapsedSectionKeys.has(item.group.key)) { + return 1; + } + return item.group.items.reduce( + (total, child) => total + countProjectThreadItemRows(child, context), + 1, + ); + } +} + +/** True when the item's subtree renders a row for the given thread. */ +export function projectThreadItemContainsThread( + item: ProjectThreadItem, + threadId: string, +): boolean { + switch (item.kind) { + case "thread": + return ( + item.node.thread.id === threadId || + item.node.children.some((child) => + projectThreadItemContainsThread(child, threadId), + ) + ); + case "environment": + return item.group.nodes.some( + (node) => + node.thread.id === threadId || + node.children.some((child) => + projectThreadItemContainsThread(child, threadId), + ), + ); + case "section": + return item.group.items.some((child) => + projectThreadItemContainsThread(child, threadId), + ); + } +} + +export interface ProjectThreadItemNavigationEntry { + threadId: string; + projectId: string; +} + +function collectThreadNodeNavigationEntries( + node: ProjectThreadNode, + context: ProjectThreadItemRowCountContext, + entries: ProjectThreadItemNavigationEntry[], +): void { + entries.push({ + threadId: node.thread.id, + projectId: node.thread.projectId, + }); + if ( + node.children.length === 0 || + context.collapsedThreadIds.has(node.thread.id) + ) { + return; + } + for (const child of node.children) { + collectProjectThreadItemNavigationEntriesInto(child, context, entries); + } +} + +function collectProjectThreadItemNavigationEntriesInto( + item: ProjectThreadItem, + context: ProjectThreadItemRowCountContext, + entries: ProjectThreadItemNavigationEntry[], +): void { + switch (item.kind) { + case "thread": + collectThreadNodeNavigationEntries(item.node, context, entries); + return; + case "environment": + if (context.collapsedEnvironmentIds.has(item.group.environmentId)) { + return; + } + for (const node of item.group.nodes) { + collectThreadNodeNavigationEntries(node, context, entries); + } + return; + case "section": + if (context.collapsedSectionKeys.has(item.group.key)) { + return; + } + for (const child of item.group.items) { + collectProjectThreadItemNavigationEntriesInto(child, context, entries); + } + return; + } +} + +/** + * The threads an item's subtree renders, in visual order, respecting the + * current collapse state. Mirrors which rows would emit + * `data-sidebar-thread-shortcut-target` anchors when mounted, so a + * windowed-out placeholder can stand in for them during keyboard navigation. + */ +export function collectProjectThreadItemNavigationEntries( + item: ProjectThreadItem, + context: ProjectThreadItemRowCountContext, +): ProjectThreadItemNavigationEntry[] { + const entries: ProjectThreadItemNavigationEntry[] = []; + collectProjectThreadItemNavigationEntriesInto(item, context, entries); + return entries; +} diff --git a/packages/client-core/src/sidebar/sectionKeys.ts b/packages/client-core/src/sidebar/sectionKeys.ts new file mode 100644 index 0000000000..5611137ebd --- /dev/null +++ b/packages/client-core/src/sidebar/sectionKeys.ts @@ -0,0 +1,19 @@ +// Pure helpers for sidebar section row identity. Section names are display text; +// membership lives in `thread.sectionId`. + +export function buildSectionKey( + containerId: string, + sectionId: string, +): string { + return `${containerId}::${sectionId}`; +} + +export function sectionKeyForThreadSection( + containerId: string, + sectionId: string | null | undefined, +): string | null { + if (!sectionId) { + return null; + } + return buildSectionKey(containerId, sectionId); +} diff --git a/packages/client-core/src/sidebar/sidebarSectionId.ts b/packages/client-core/src/sidebar/sidebarSectionId.ts new file mode 100644 index 0000000000..18cc634743 --- /dev/null +++ b/packages/client-core/src/sidebar/sidebarSectionId.ts @@ -0,0 +1,9 @@ +// Identity of a top-level sidebar section. Built-in sections are "pinned" and +// "threads"; entity sections are keyed by kind and id. +export type SidebarSectionId = + | "pinned" + | "threads" + | `project:${string}` + | `section:${string}` + | `machine:${string}`; +export type CollapsibleSidebarSectionId = "pinned" | "threads"; diff --git a/packages/client-core/src/sidebar/sidebarSectionOrder.ts b/packages/client-core/src/sidebar/sidebarSectionOrder.ts new file mode 100644 index 0000000000..c3aa217228 --- /dev/null +++ b/packages/client-core/src/sidebar/sidebarSectionOrder.ts @@ -0,0 +1,134 @@ +import type { SidebarSectionId } from "./sidebarSectionId.js"; +import { + applyNeighborReorder, + buildNeighborReorderRequest, +} from "./neighbor-reorder.js"; + +export type SidebarEntitySectionKind = "project" | "section" | "machine"; +export type LegacySidebarEntityAnchor = "projects" | "sections" | "machines"; + +export function buildSidebarEntitySectionId( + kind: SidebarEntitySectionKind, + id: string, +): SidebarSectionId { + return `${kind}:${id}`; +} + +export function isSidebarSectionId(value: string): value is SidebarSectionId { + return ( + value === "pinned" || + value === "threads" || + value.startsWith("project:") || + value.startsWith("section:") || + value.startsWith("machine:") + ); +} + +interface ReorderSidebarSectionOrderArgs { + activeId: string; + overId: string; + order: readonly SidebarSectionId[]; +} + +export function reorderSidebarSectionOrder({ + activeId, + overId, + order, +}: ReorderSidebarSectionOrderArgs): SidebarSectionId[] | null { + if (!isSidebarSectionId(activeId) || !isSidebarSectionId(overId)) { + return null; + } + const items = order.map((id) => ({ id })); + const request = buildNeighborReorderRequest({ activeId, overId, items }); + if (!request) return null; + return applyNeighborReorder({ items, request }) + .map((item) => item.id) + .filter(isSidebarSectionId); +} + +interface NormalizeSidebarSectionOrderArgs { + storedOrder: readonly string[]; + entitySectionIds: readonly SidebarSectionId[]; + legacyEntityAnchor: LegacySidebarEntityAnchor; + hasPinnedSection: boolean; + hasThreadsSection?: boolean; +} + +/** + * Reconciles locally persisted order with the live entity set. The old + * aggregate section token is expanded in place, so existing users keep their + * Pinned/primary/Threads layout when projects and sections become first-level + * sections. New entities join after the last entity without disturbing a + * user's explicit placement of built-in sections. + */ +export function normalizeSidebarSectionOrder({ + storedOrder, + entitySectionIds, + legacyEntityAnchor, + hasPinnedSection, + hasThreadsSection = true, +}: NormalizeSidebarSectionOrderArgs): SidebarSectionId[] { + const available = new Set([ + ...(hasPinnedSection ? (["pinned"] as const) : []), + ...entitySectionIds, + ...(hasThreadsSection ? (["threads"] as const) : []), + ]); + const entitySet = new Set(entitySectionIds); + const seen = new Set(); + const normalized: SidebarSectionId[] = []; + let expandedLegacyAnchor = false; + + const append = (sectionId: SidebarSectionId) => { + if (!available.has(sectionId) || seen.has(sectionId)) { + return; + } + seen.add(sectionId); + normalized.push(sectionId); + }; + + for (const storedId of storedOrder) { + if (storedId === legacyEntityAnchor) { + expandedLegacyAnchor = true; + for (const entityId of entitySectionIds) { + append(entityId); + } + continue; + } + if (isSidebarSectionId(storedId)) { + append(storedId); + } + } + + if (hasPinnedSection && !seen.has("pinned")) { + normalized.unshift("pinned"); + seen.add("pinned"); + } + + const missingEntities = entitySectionIds.filter( + (sectionId) => !seen.has(sectionId), + ); + if (missingEntities.length > 0) { + const lastEntityIndex = normalized.reduce( + (lastIndex, sectionId, index) => + entitySet.has(sectionId) ? index : lastIndex, + -1, + ); + const threadsIndex = normalized.indexOf("threads"); + const insertionIndex = + lastEntityIndex >= 0 + ? lastEntityIndex + 1 + : threadsIndex >= 0 || expandedLegacyAnchor + ? Math.max(threadsIndex, 0) + : normalized.length; + normalized.splice(insertionIndex, 0, ...missingEntities); + for (const sectionId of missingEntities) { + seen.add(sectionId); + } + } + + if (hasThreadsSection && !seen.has("threads")) { + normalized.push("threads"); + } + + return normalized; +} diff --git a/packages/client-core/src/sidebar/threadReadState.ts b/packages/client-core/src/sidebar/threadReadState.ts new file mode 100644 index 0000000000..5b5fcfe9ba --- /dev/null +++ b/packages/client-core/src/sidebar/threadReadState.ts @@ -0,0 +1,10 @@ +import type { Thread } from "@bb/domain"; +import { isThreadRead } from "../thread/thread-read-state.js"; + +type ThreadReadToggleAction = "mark_read" | "mark_unread"; + +export function getThreadReadToggleAction( + thread: Pick, +): ThreadReadToggleAction { + return isThreadRead(thread) ? "mark_unread" : "mark_read"; +} diff --git a/packages/client-core/src/terminal/terminal-websocket-path.ts b/packages/client-core/src/terminal/terminal-websocket-path.ts new file mode 100644 index 0000000000..dbf4af54f3 --- /dev/null +++ b/packages/client-core/src/terminal/terminal-websocket-path.ts @@ -0,0 +1,10 @@ +export interface BuildTerminalWebSocketPathArgs { + terminalId: string; +} + +/** Server path of the terminal attach socket, relative to the app origin. */ +export function buildTerminalWebSocketPath({ + terminalId, +}: BuildTerminalWebSocketPathArgs): string { + return `/ws/terminals/${encodeURIComponent(terminalId)}`; +} diff --git a/packages/client-core/src/terminal/terminal-websocket-transport.ts b/packages/client-core/src/terminal/terminal-websocket-transport.ts new file mode 100644 index 0000000000..5bc25f130c --- /dev/null +++ b/packages/client-core/src/terminal/terminal-websocket-transport.ts @@ -0,0 +1,456 @@ +import { getTerminalBase64DecodedByteLength } from "@bb/domain"; +import { + terminalServerMessageSchema, + type TerminalServerMessage, +} from "@bb/server-contract"; + +const SOCKET_OPEN = 1; +const DEFAULT_INPUT_QUEUE_MAX_BYTES = 1024 * 1024; +const DEFAULT_SOCKET_HIGH_WATER_BYTES = 1024 * 1024; +const DEFAULT_DRAIN_POLL_MS = 10; +const DEFAULT_HEARTBEAT_INTERVAL_MS = 15_000; +const DEFAULT_HEARTBEAT_TIMEOUT_MS = 45_000; +const DEFAULT_RECONNECT_DELAYS_MS = [100, 250, 500, 1_000, 2_000] as const; + +export type TerminalSocketConnectionState = + | "connecting" + | "open" + | "reconnecting" + | "closed"; + +export interface TerminalBrowserSocket { + /** + * Bytes queued but not yet flushed to the network. Browser sockets always + * report it; React Native's WebSocket never sets it, so it may be + * `undefined` — treated as "nothing buffered". + */ + bufferedAmount?: number; + close(code?: number, reason?: string): void; + onclose: ((event: CloseEvent) => void) | null; + onerror: ((event: Event) => void) | null; + onmessage: ((event: MessageEvent) => void) | null; + onopen: ((event: Event) => void) | null; + readonly readyState: number; + send(data: string): void; +} + +export type CreateTerminalBrowserSocket = ( + url: string, +) => TerminalBrowserSocket; + +function socketBufferedAmount(socket: TerminalBrowserSocket): number { + return socket.bufferedAmount ?? 0; +} + +function terminalSocketUrlWithSinceSeq(url: string, sinceSeq: number): string { + const parsed = new URL(url); + parsed.searchParams.set("sinceSeq", String(sinceSeq)); + return parsed.toString(); +} + +interface PendingTerminalInput { + bytes: number; + payload: string; +} + +export interface TerminalWebSocketTransportOptions { + createSocket?: CreateTerminalBrowserSocket; + drainPollMs?: number; + heartbeatIntervalMs?: number; + heartbeatTimeoutMs?: number; + inputQueueMaxBytes?: number; + now?: () => number; + onConnectionState?: (state: TerminalSocketConnectionState) => void; + onInputOverflow?: (maxBytes: number) => void; + onInvalidMessage?: () => void; + onMessage: (message: TerminalServerMessage) => void; + onSequenceGap?: (expectedSeq: number, receivedSeq: number) => void; + reconnectDelaysMs?: readonly number[]; + shouldReconnect: () => boolean; + socketHighWaterBytes?: number; + url: string; +} + +export class TerminalWebSocketTransport { + private readonly createSocket: CreateTerminalBrowserSocket; + private readonly drainPollMs: number; + private readonly heartbeatIntervalMs: number; + private readonly heartbeatTimeoutMs: number; + private readonly inputQueueMaxBytes: number; + private readonly now: () => number; + private readonly reconnectDelaysMs: readonly number[]; + private readonly socketHighWaterBytes: number; + private drainTimeout: ReturnType | null = null; + private disposed = false; + private heartbeatInterval: ReturnType | null = null; + private lastPongAt = 0; + private lastResize: { cols: number; rows: number } | null = null; + private nextOutputSeq = 0; + private pendingInputBytes = 0; + private readonly pendingInputs: PendingTerminalInput[] = []; + private reconnectAttempt = 0; + private reconnectTimeout: ReturnType | null = null; + private socket: TerminalBrowserSocket | null = null; + private started = false; + private suspended = false; + private terminalEnded = false; + + constructor(private readonly options: TerminalWebSocketTransportOptions) { + this.createSocket = options.createSocket ?? ((url) => new WebSocket(url)); + this.drainPollMs = options.drainPollMs ?? DEFAULT_DRAIN_POLL_MS; + this.heartbeatIntervalMs = + options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS; + this.heartbeatTimeoutMs = + options.heartbeatTimeoutMs ?? DEFAULT_HEARTBEAT_TIMEOUT_MS; + this.inputQueueMaxBytes = + options.inputQueueMaxBytes ?? DEFAULT_INPUT_QUEUE_MAX_BYTES; + this.now = options.now ?? Date.now; + this.reconnectDelaysMs = + options.reconnectDelaysMs ?? DEFAULT_RECONNECT_DELAYS_MS; + this.socketHighWaterBytes = + options.socketHighWaterBytes ?? DEFAULT_SOCKET_HIGH_WATER_BYTES; + } + + start(): void { + if (this.disposed || this.socket !== null) { + return; + } + // Record the intent even while suspended so {@link resume} connects. + this.started = true; + if (this.suspended) { + return; + } + this.connect("connecting"); + } + + /** + * Close the socket without reconnecting (a mobile app going to the + * background). Queued input and the last resize are kept; `nextOutputSeq` + * is kept so {@link resume} reattaches with `sinceSeq` at the last chunk + * seen and the server replays only what was missed. + */ + suspend(): void { + if (this.disposed || this.suspended) { + return; + } + this.suspended = true; + this.clearReconnectTimeout(); + this.clearDrainTimeout(); + this.stopHeartbeat(); + const socket = this.socket; + this.socket = null; + if (socket !== null) { + socket.onclose = null; + socket.onerror = null; + socket.onmessage = null; + socket.onopen = null; + socket.close(1000, "suspended"); + } + this.options.onConnectionState?.("closed"); + } + + /** Reconnect after {@link suspend}; a no-op when not suspended. */ + resume(): void { + if (this.disposed || !this.suspended) { + return; + } + this.suspended = false; + if (!this.started || this.terminalEnded || this.socket !== null) { + return; + } + this.reconnectAttempt = 0; + this.connect("reconnecting"); + } + + dispose(): void { + if (this.disposed) { + return; + } + this.disposed = true; + this.clearReconnectTimeout(); + this.clearDrainTimeout(); + this.stopHeartbeat(); + const socket = this.socket; + this.socket = null; + if (socket !== null) { + socket.onclose = null; + socket.onerror = null; + socket.onmessage = null; + socket.onopen = null; + socket.close(); + } + this.options.onConnectionState?.("closed"); + } + + sendInput(dataBase64: string): boolean { + const pending = { + bytes: getTerminalBase64DecodedByteLength(dataBase64), + payload: JSON.stringify({ + type: "input", + dataBase64, + }), + }; + const socket = this.socket; + if ( + socket !== null && + socket.readyState === SOCKET_OPEN && + socketBufferedAmount(socket) <= this.socketHighWaterBytes && + this.pendingInputs.length === 0 + ) { + if (this.trySend(socket, pending.payload)) { + return true; + } + } + return this.enqueueInput(pending); + } + + sendResize(cols: number, rows: number): void { + if (this.lastResize?.cols === cols && this.lastResize.rows === rows) { + return; + } + this.lastResize = { cols, rows }; + const socket = this.socket; + if (socket === null || socket.readyState !== SOCKET_OPEN) { + return; + } + this.trySend( + socket, + JSON.stringify({ + type: "resize", + cols, + rows, + }), + ); + } + + private connect(state: "connecting" | "reconnecting"): void { + if (this.disposed || this.suspended || this.terminalEnded) { + return; + } + this.options.onConnectionState?.(state); + let socket: TerminalBrowserSocket; + try { + socket = this.createSocket( + terminalSocketUrlWithSinceSeq(this.options.url, this.nextOutputSeq), + ); + } catch { + this.scheduleReconnect(); + return; + } + this.socket = socket; + socket.onopen = () => this.handleOpen(socket); + socket.onmessage = (event) => this.handleMessage(socket, event.data); + socket.onerror = () => undefined; + socket.onclose = () => this.handleClose(socket); + } + + private handleOpen(socket: TerminalBrowserSocket): void { + if (this.disposed || this.socket !== socket) { + return; + } + this.reconnectAttempt = 0; + this.lastPongAt = this.now(); + this.options.onConnectionState?.("open"); + this.startHeartbeat(socket); + if (this.lastResize !== null) { + this.trySend( + socket, + JSON.stringify({ type: "resize", ...this.lastResize }), + ); + } + this.flushInputs(); + } + + private handleMessage(socket: TerminalBrowserSocket, raw: unknown): void { + if (this.disposed || this.socket !== socket || typeof raw !== "string") { + return; + } + let decoded: unknown; + try { + decoded = JSON.parse(raw); + } catch { + this.options.onInvalidMessage?.(); + return; + } + const parsed = terminalServerMessageSchema.safeParse(decoded); + if (!parsed.success) { + this.options.onInvalidMessage?.(); + return; + } + const message = parsed.data; + if (message.type === "pong") { + this.lastPongAt = this.now(); + } + if ( + message.type === "attached" && + message.replayStartSeq > this.nextOutputSeq + ) { + this.options.onSequenceGap?.(this.nextOutputSeq, message.replayStartSeq); + this.nextOutputSeq = message.replayStartSeq; + } + if (message.type === "output") { + if (message.chunk.seq < this.nextOutputSeq) { + return; + } + if (message.chunk.seq > this.nextOutputSeq) { + this.options.onSequenceGap?.(this.nextOutputSeq, message.chunk.seq); + } + this.nextOutputSeq = message.chunk.seq + 1; + } + if (message.type === "exited") { + this.terminalEnded = true; + this.clearReconnectTimeout(); + } + if ( + message.type === "error" && + [ + "terminal_exited", + "terminal_not_found", + "terminal_not_running", + ].includes(message.code) + ) { + this.terminalEnded = true; + this.clearReconnectTimeout(); + } + this.options.onMessage(message); + } + + private handleClose(socket: TerminalBrowserSocket): void { + if (this.socket !== socket) { + return; + } + this.socket = null; + this.stopHeartbeat(); + this.clearDrainTimeout(); + if ( + this.disposed || + this.terminalEnded || + !this.options.shouldReconnect() + ) { + this.options.onConnectionState?.("closed"); + return; + } + this.scheduleReconnect(); + } + + private scheduleReconnect(): void { + if ( + this.disposed || + this.suspended || + this.terminalEnded || + this.reconnectTimeout !== null || + !this.options.shouldReconnect() + ) { + return; + } + this.options.onConnectionState?.("reconnecting"); + const delayIndex = Math.min( + this.reconnectAttempt, + this.reconnectDelaysMs.length - 1, + ); + const delay = this.reconnectDelaysMs[delayIndex] ?? 0; + this.reconnectAttempt += 1; + this.reconnectTimeout = setTimeout(() => { + this.reconnectTimeout = null; + this.connect("reconnecting"); + }, delay); + } + + private enqueueInput(pending: PendingTerminalInput): boolean { + if (this.pendingInputBytes + pending.bytes > this.inputQueueMaxBytes) { + this.options.onInputOverflow?.(this.inputQueueMaxBytes); + return false; + } + this.pendingInputs.push(pending); + this.pendingInputBytes += pending.bytes; + this.scheduleDrain(); + return true; + } + + private flushInputs(): void { + this.clearDrainTimeout(); + const socket = this.socket; + if (socket === null || socket.readyState !== SOCKET_OPEN) { + return; + } + while ( + this.pendingInputs.length > 0 && + socketBufferedAmount(socket) <= this.socketHighWaterBytes + ) { + const pending = this.pendingInputs[0]; + if (!pending || !this.trySend(socket, pending.payload)) { + break; + } + this.pendingInputs.shift(); + this.pendingInputBytes -= pending.bytes; + } + if (this.pendingInputs.length > 0) { + this.scheduleDrain(); + } + } + + private trySend(socket: TerminalBrowserSocket, payload: string): boolean { + if (socket.readyState !== SOCKET_OPEN) { + return false; + } + try { + socket.send(payload); + return true; + } catch { + try { + socket.close(1011, "send-failed"); + } catch { + this.handleClose(socket); + } + return false; + } + } + + private scheduleDrain(): void { + if (this.drainTimeout !== null || this.socket?.readyState !== SOCKET_OPEN) { + return; + } + this.drainTimeout = setTimeout(() => { + this.drainTimeout = null; + this.flushInputs(); + }, this.drainPollMs); + } + + private clearDrainTimeout(): void { + if (this.drainTimeout === null) { + return; + } + clearTimeout(this.drainTimeout); + this.drainTimeout = null; + } + + private clearReconnectTimeout(): void { + if (this.reconnectTimeout === null) { + return; + } + clearTimeout(this.reconnectTimeout); + this.reconnectTimeout = null; + } + + private startHeartbeat(socket: TerminalBrowserSocket): void { + this.stopHeartbeat(); + this.heartbeatInterval = setInterval(() => { + if (this.socket !== socket || socket.readyState !== SOCKET_OPEN) { + return; + } + if (this.now() - this.lastPongAt > this.heartbeatTimeoutMs) { + socket.close(4000, "heartbeat-timeout"); + return; + } + this.trySend(socket, JSON.stringify({ type: "ping" })); + }, this.heartbeatIntervalMs); + } + + private stopHeartbeat(): void { + if (this.heartbeatInterval === null) { + return; + } + clearInterval(this.heartbeatInterval); + this.heartbeatInterval = null; + } +} diff --git a/packages/client-core/src/thread/thread-activity.ts b/packages/client-core/src/thread/thread-activity.ts new file mode 100644 index 0000000000..91f152cebd --- /dev/null +++ b/packages/client-core/src/thread/thread-activity.ts @@ -0,0 +1,301 @@ +import { assertNever } from "@bb/core-ui"; +import type { Thread, ThreadListEntry, ThreadWithRuntime } from "@bb/domain"; +// Imported from the defining leaf module, not the timeline barrel: the sidebar +// thread list reaches this helper before first paint, and the barrel would pull +// the whole timeline (and @pierre/diffs, Shiki, KaTeX behind it) onto the boot +// path for one predicate. +import { isRunningThreadRuntimeDisplayStatus } from "../timeline/thread-runtime-status.js"; +import { isThreadRead } from "./thread-read-state.js"; + +type ThreadStatusShape = Pick< + Thread, + "status" | "lastReadAt" | "latestAttentionAt" | "parentThreadId" +>; + +type ThreadRuntimeShape = Pick; +type ThreadActivityStateShape = Pick; + +export function isRuntimeBusyThread(thread: ThreadRuntimeShape): boolean { + return isRunningThreadRuntimeDisplayStatus(thread.runtime.displayStatus); +} + +export function hasActiveWorkflowActivity( + thread: ThreadActivityStateShape, +): boolean { + return thread.activity.activeWorkflowCount > 0; +} + +export function hasActiveBackgroundAgentActivity( + thread: ThreadActivityStateShape, +): boolean { + return thread.activity.activeBackgroundAgentCount > 0; +} + +export function hasActiveBackgroundCommandActivity( + thread: ThreadActivityStateShape, +): boolean { + return thread.activity.activeBackgroundCommandCount > 0; +} + +export function hasActivePlanModeActivity( + thread: ThreadActivityStateShape, +): boolean { + return thread.activity.activePlanModeCount > 0; +} + +export function hasActiveGoalActivity( + thread: ThreadActivityStateShape, +): boolean { + return thread.activity.activeGoalCount > 0; +} + +export interface ThreadListIndicatorState { + hasPendingInteraction: boolean; + hasUnsubmittedDraft: boolean; + hasUnreadError: boolean; + hasUnreadSuccess: boolean; + isBackgroundAgentActive: boolean; + isBackgroundCommandActive: boolean; + isGoalActive: boolean; + isPlanModeActive: boolean; + isRuntimeActive: boolean; + isWorkflowActive: boolean; +} + +export type ThreadListIndicatorKind = + | "unread-error" + | "waiting-for-input" + | "working-draft" + | "workflow" + | "background-agent" + | "background-command" + | "plan-mode" + | "goal" + | "runtime" + | "draft" + | "unread-success" + | "none"; + +const THREAD_LIST_INDICATOR_LABELS: Record< + Exclude, + string +> = { + "unread-error": "Unread thread failed", + "waiting-for-input": "Thread needs user input", + "working-draft": "Thread working with unsubmitted draft", + workflow: "Workflow running", + "background-agent": "Background agent running", + "background-command": "Background command running", + "plan-mode": "Plan mode active", + goal: "Goal active", + runtime: "Thread working", + draft: "Thread has unsubmitted draft", + "unread-success": "Unread thread succeeded", +}; + +export function getThreadListIndicatorLabel( + kind: ThreadListIndicatorKind, +): string | null { + return kind === "none" ? null : THREAD_LIST_INDICATOR_LABELS[kind]; +} + +/** + * Whether a thread-list row has active work, independent of which status wins + * the single trailing indicator slot. Attention states such as unread errors + * and pending input can outrank background work visually without making that + * work stop; split membership uses this predicate to retain its shimmer. + */ +export function hasThreadListWorkingActivity( + state: ThreadListIndicatorState, + hasRunningPluginStatus = false, +): boolean { + return ( + state.isRuntimeActive || + state.isWorkflowActive || + state.isBackgroundAgentActive || + state.isBackgroundCommandActive || + state.isPlanModeActive || + state.isGoalActive || + hasRunningPluginStatus + ); +} + +/** + * Resolves the one trailing indicator slot from independent, unsuppressed + * thread state. Keep all precedence here so every thread-list surface makes + * the same choice when activities overlap. + */ +export function resolveThreadListIndicator( + state: ThreadListIndicatorState, +): ThreadListIndicatorKind { + // Attention states come first: the runtime stays active for the whole time a + // question or approval is open, so ranking "runtime" above them would hide the + // one state the user can act on behind a spinner that never resolves on its + // own. Plan and goal outrank the spinner too — they describe how the current + // turn is running, and their glyphs shimmer, so they already read as working. + // Only ambient work the row can't otherwise explain sits below the spinner. + if (state.hasUnreadError) return "unread-error"; + if (state.hasPendingInteraction) return "waiting-for-input"; + + const hasActiveWork = hasThreadListWorkingActivity(state); + if (state.hasUnsubmittedDraft && hasActiveWork) return "working-draft"; + if (state.isPlanModeActive) return "plan-mode"; + if (state.isGoalActive) return "goal"; + if (state.isRuntimeActive) return "runtime"; + if (state.isWorkflowActive) return "workflow"; + if (state.isBackgroundAgentActive) return "background-agent"; + if (state.isBackgroundCommandActive) return "background-command"; + if (state.hasUnsubmittedDraft) return "draft"; + if (state.hasUnreadSuccess) return "unread-success"; + return "none"; +} + +/** + * The signals a collapsed parent row surfaces on behalf of its hidden children. + * A collapsed row renders these through its single trailing status glyph, using + * the same priority as a leaf row through `resolveThreadListIndicator`. + * Expanded rows show their own status, + * since the children are then visible with their own glyphs. Background + * agent, command, and workflow work are tracked separately from runtime work so + * the sidebar can use task-specific signals instead of collapsing them into a + * generic spinner. + */ +export interface CollapsedChildActivity { + /** At least one child is blocked on the user (needs input). */ + pending: boolean; + /** At least one child is actively working, including workflow work. */ + working: boolean; + /** At least one child has an unsubmitted composer draft. */ + hasUnsubmittedDraft: boolean; + /** At least one child is actively running a foreground/runtime turn. */ + runtimeWorking: boolean; + /** At least one idle child has a provider workflow still running. */ + workflow: boolean; + /** At least one child has a background agent or subagent still running. */ + backgroundAgent: boolean; + /** At least one child has a background shell command still running. */ + backgroundCommand: boolean; + /** At least one child is showing the plan-mode banner above the composer. */ + planMode: boolean; + /** At least one child is showing the active-goal banner above the composer. */ + goal: boolean; + /** At least one successfully finished child is unread. */ + unread: boolean; + /** At least one unread child has reached the terminal error state. */ + unreadError: boolean; +} + +export const NO_COLLAPSED_CHILD_ACTIVITY: CollapsedChildActivity = { + pending: false, + working: false, + hasUnsubmittedDraft: false, + runtimeWorking: false, + workflow: false, + backgroundAgent: false, + backgroundCommand: false, + planMode: false, + goal: false, + unread: false, + unreadError: false, +}; + +type ThreadActivityShape = ThreadStatusShape & + ThreadRuntimeShape & + Pick; + +const EMPTY_DRAFT_THREAD_IDS: ReadonlySet = new Set(); + +/** Rolls a child thread list up to the set of activity signals present in it. */ +export function getCollapsedChildActivity( + threads: readonly ThreadActivityShape[], + draftThreadIds: ReadonlySet = EMPTY_DRAFT_THREAD_IDS, +): CollapsedChildActivity { + let pending = false; + let working = false; + let hasUnsubmittedDraft = false; + let runtimeWorking = false; + let workflow = false; + let backgroundAgent = false; + let backgroundCommand = false; + let planMode = false; + let goal = false; + let unread = false; + let unreadError = false; + for (const thread of threads) { + if (draftThreadIds.has(thread.id)) { + hasUnsubmittedDraft = true; + } + const childUnreadDone = isUnreadDoneThread(thread); + if (childUnreadDone && thread.status === "error") { + unreadError = true; + } else if (childUnreadDone) { + unread = true; + } + + if (thread.hasPendingInteraction) { + pending = true; + } + const childRuntimeWorking = isRuntimeBusyThread(thread); + const childWorkflowActive = hasActiveWorkflowActivity(thread); + const childBackgroundAgentActive = hasActiveBackgroundAgentActivity(thread); + const childBackgroundCommandActive = + hasActiveBackgroundCommandActivity(thread); + const childPlanModeActive = hasActivePlanModeActivity(thread); + const childGoalActive = hasActiveGoalActivity(thread); + if (childRuntimeWorking) { + runtimeWorking = true; + working = true; + } + if (childWorkflowActive) { + workflow = true; + working = true; + } + if (childBackgroundAgentActive) { + backgroundAgent = true; + working = true; + } + if (childBackgroundCommandActive) { + backgroundCommand = true; + working = true; + } + if (childPlanModeActive) { + planMode = true; + working = true; + } + if (childGoalActive) { + goal = true; + working = true; + } + } + return { + pending, + working, + hasUnsubmittedDraft, + runtimeWorking, + workflow, + backgroundAgent, + backgroundCommand, + planMode, + goal, + unread, + unreadError, + }; +} + +export function isUnreadDoneThread(thread: ThreadStatusShape): boolean { + if (thread.parentThreadId != null) { + return false; + } + + switch (thread.status) { + case "error": + case "idle": + return !isThreadRead(thread); + case "active": + case "starting": + case "stopping": + return false; + default: + return assertNever(thread.status); + } +} diff --git a/packages/client-core/src/thread/thread-read-state.ts b/packages/client-core/src/thread/thread-read-state.ts new file mode 100644 index 0000000000..0db2f41f74 --- /dev/null +++ b/packages/client-core/src/thread/thread-read-state.ts @@ -0,0 +1,7 @@ +import type { Thread } from "@bb/domain"; + +export type ThreadReadState = Pick; + +export function isThreadRead(thread: ThreadReadState): boolean { + return (thread.lastReadAt ?? 0) >= thread.latestAttentionAt; +} diff --git a/packages/client-core/src/timeline/compute-muted-prefix-length.ts b/packages/client-core/src/timeline/compute-muted-prefix-length.ts new file mode 100644 index 0000000000..185980b707 --- /dev/null +++ b/packages/client-core/src/timeline/compute-muted-prefix-length.ts @@ -0,0 +1,28 @@ +import type { TimelineUserConversationRow } from "@bb/server-contract"; + +/** + * Detect the closing bracket of a `[bb …]` prefix on non-user messages so the + * renderer can split generated-message chrome from the user-readable body. We + * never extract data from the prefix — only locate its boundary based on the + * leading `[bb` marker. Trailing whitespace after `]` is absorbed into the + * prefix region so block (`\n\n`) and inline (` `) writer-side separators + * render identically: header on one line, body directly below, with no blank + * gap. + * + * Returns the index in `text` where the body begins. `0` means "no muted + * prefix" — render the text plain. + */ +export function computeMutedPrefixLength( + initiator: TimelineUserConversationRow["initiator"], + text: string, +): number { + if (initiator === "user") return 0; + if (!text.startsWith("[bb")) return 0; + const closeIdx = text.indexOf("]"); + if (closeIdx === -1) return 0; + let endIdx = closeIdx + 1; + while (endIdx < text.length && /\s/.test(text.charAt(endIdx))) { + endIdx += 1; + } + return endIdx; +} diff --git a/packages/client-core/src/timeline/conversation-message-limits.ts b/packages/client-core/src/timeline/conversation-message-limits.ts new file mode 100644 index 0000000000..9daa104a56 --- /dev/null +++ b/packages/client-core/src/timeline/conversation-message-limits.ts @@ -0,0 +1,107 @@ +import { isRawThreadId } from "@bb/domain"; + +export const USER_MESSAGE_CHAR_CAP = 4096; + +// Generated rows are collapsed by default, so keep their initial Markdown +// parse under the same bounded budget as collapsed authored messages. +export const GENERATED_MESSAGE_COLLAPSED_PREVIEW_CHAR_CAP = + USER_MESSAGE_CHAR_CAP; + +export interface BoundedMarkdownPreview { + parseAsMarkdown: boolean; + text: string; + wasCapped: boolean; +} + +function isWhitespace(value: string | undefined): boolean { + return value !== undefined && /\s/u.test(value); +} + +export function endsInsideExactRawThreadIdCodeSpan(text: string): boolean { + let openDelimiterLength = 0; + let openContentStart = -1; + for (let index = 0; index < text.length; index++) { + if (text[index] !== "`" || isEscapedBacktick(text, index)) continue; + let delimiterEnd = index + 1; + while (text[delimiterEnd] === "`") delimiterEnd += 1; + const delimiterLength = delimiterEnd - index; + if (openDelimiterLength === 0) { + openDelimiterLength = delimiterLength; + openContentStart = delimiterEnd; + } else if (delimiterLength === openDelimiterLength) { + openDelimiterLength = 0; + openContentStart = -1; + } + index = delimiterEnd - 1; + } + return ( + openDelimiterLength > 0 && + openContentStart >= 0 && + isRawThreadId(text.slice(openContentStart)) + ); +} + +function cappedMarkdownPreview(text: string): BoundedMarkdownPreview { + return { + parseAsMarkdown: !endsInsideExactRawThreadIdCodeSpan(text), + text, + wasCapped: true, + }; +} + +/** + * Bounds Markdown before parsing without manufacturing a complete token at the + * cut. If the cap bisects a token, retreat to whitespace; a single unbroken + * token stays plain text until the user explicitly expands it. + */ +export function boundedMarkdownPreview( + text: string, + cap: number, +): BoundedMarkdownPreview { + if (text.length <= cap) { + return { parseAsMarkdown: true, text, wasCapped: false }; + } + + const previewWindow = text.slice(0, cap + 1); + const cappedText = previewWindow.slice(0, cap); + const capSplitsToken = + !isWhitespace(cappedText.at(-1)) && !isWhitespace(previewWindow[cap]); + if (!capSplitsToken) { + return cappedMarkdownPreview(cappedText); + } + + const lastWhitespaceIndex = cappedText.search(/\s(?=\S*$)/u); + if (lastWhitespaceIndex < 0) { + return { parseAsMarkdown: false, text: cappedText, wasCapped: true }; + } + + return cappedMarkdownPreview(cappedText.slice(0, lastWhitespaceIndex + 1)); +} + +function isEscapedBacktick(text: string, index: number): boolean { + let slashCount = 0; + for (let cursor = index - 1; cursor >= 0 && text[cursor] === "\\"; cursor--) { + slashCount += 1; + } + return slashCount % 2 === 1; +} + +/** Closes a code span cut by a preview cap without adding visible text. */ +export function closeUnterminatedMarkdownCodeSpan(text: string): string { + let openDelimiterLength = 0; + for (let index = 0; index < text.length; index++) { + if (text[index] !== "`" || isEscapedBacktick(text, index)) continue; + let delimiterEnd = index + 1; + while (text[delimiterEnd] === "`") delimiterEnd += 1; + const delimiterLength = delimiterEnd - index; + if (openDelimiterLength === 0) { + openDelimiterLength = delimiterLength; + } else if (delimiterLength === openDelimiterLength) { + openDelimiterLength = 0; + } + index = delimiterEnd - 1; + } + return openDelimiterLength === 0 + ? text + : `${text}${"`".repeat(openDelimiterLength)}`; +} diff --git a/packages/client-core/src/timeline/conversation-turn-request-label.ts b/packages/client-core/src/timeline/conversation-turn-request-label.ts new file mode 100644 index 0000000000..5ca93bedfa --- /dev/null +++ b/packages/client-core/src/timeline/conversation-turn-request-label.ts @@ -0,0 +1,12 @@ +import type { TimelineConversationTurnRequest } from "@bb/server-contract"; + +export function turnRequestLabel( + turnRequest: TimelineConversationTurnRequest, +): string | null { + if (turnRequest.kind !== "steer") { + return null; + } + if (turnRequest.status === "pending") return "Steer pending"; + if (turnRequest.status === "rejected") return "Steer failed"; + return "Steer"; +} diff --git a/packages/client-core/src/timeline/optimistic-timeline-row.ts b/packages/client-core/src/timeline/optimistic-timeline-row.ts new file mode 100644 index 0000000000..b621f5a5c5 --- /dev/null +++ b/packages/client-core/src/timeline/optimistic-timeline-row.ts @@ -0,0 +1,15 @@ +/** + * Client-only timeline rows: the user message a send renders immediately, + * before the server's own row for it exists. + * + * An optimistic row lives in the timeline query cache and is dropped from it + * by the refetch that brings the server's row. Because its id is minted + * locally it can never match a server row id, so any consumer that merges + * cached snapshots has to treat it as non-durable — a retained copy would sit + * alongside the server row instead of being replaced by it. + */ +export const OPTIMISTIC_TIMELINE_ROW_ID_PREFIX = "optimistic-user-"; + +export function isOptimisticTimelineRowId(id: string): boolean { + return id.startsWith(OPTIMISTIC_TIMELINE_ROW_ID_PREFIX); +} diff --git a/packages/client-core/src/timeline/thread-runtime-status.ts b/packages/client-core/src/timeline/thread-runtime-status.ts new file mode 100644 index 0000000000..bc2ac32283 --- /dev/null +++ b/packages/client-core/src/timeline/thread-runtime-status.ts @@ -0,0 +1,21 @@ +import { assertNever } from "@bb/core-ui"; +import type { ThreadRuntimeDisplayStatus } from "@bb/domain"; + +export function isRunningThreadRuntimeDisplayStatus( + status: ThreadRuntimeDisplayStatus, +): boolean { + switch (status) { + case "active": + case "host-reconnecting": + case "provisioning": + case "starting": + case "stopping": + return true; + case "error": + case "idle": + case "waiting-for-host": + return false; + default: + return assertNever(status); + } +} diff --git a/packages/client-core/src/timeline/timeline-auto-expand.ts b/packages/client-core/src/timeline/timeline-auto-expand.ts new file mode 100644 index 0000000000..c2db72cf5c --- /dev/null +++ b/packages/client-core/src/timeline/timeline-auto-expand.ts @@ -0,0 +1,194 @@ +import { + assertNever, + findTimelineFrontierRow, + hasTimelineExplorationIntent, + type ThreadTimelineViewRow, + type TimelineViewWorkRow, +} from "@bb/thread-view"; + +interface CollectTimelineAutoExpansionRowIdsArgs { + rows: readonly ThreadTimelineViewRow[]; + scopeActive: boolean; +} + +export interface TimelineAutoExpansionRowIds { + liveFrontierRowIds: ReadonlySet; + terminalFrontierRowIds: ReadonlySet; +} + +export function isWorkRowExpandable(row: TimelineViewWorkRow): boolean { + switch (row.workKind) { + case "web-search": + case "web-fetch": + case "approval": + return false; + case "image-view": + return true; + case "question": + // Resolving and answered rows both carry a recorded answer in their + // body. Pending/interrupted stay title-only. Matches the + // body-collapse rule in QuestionWorkRowBody. + return row.lifecycle === "answered" || row.lifecycle === "resolving"; + case "command": + case "tool": + return !hasTimelineExplorationIntent(row); + case "file-change": + return true; + case "delegation": + return row.childRows.length > 0 || row.output.trim().length > 0; + case "workflow": + // The phase/agent tree (or terminal summary/error) lives in the body; a + // degraded row with none of them stays title-only. Matches the + // body-collapse rule in WorkflowWorkRowBody. + return ( + row.workflow !== null || row.summary !== null || row.error !== null + ); + default: + return assertNever(row); + } +} + +export function isRowExpandable(row: ThreadTimelineViewRow): boolean { + switch (row.kind) { + case "conversation": + return false; + case "system": + return row.detail !== null && row.detail.trim().length > 0; + case "bundle-summary": + case "step-summary": + return row.children.length > 0; + case "turn": + return true; + case "work": + return isWorkRowExpandable(row); + default: + return assertNever(row); + } +} + +/** + * Bundle and step summaries whose children are all non-expandable get the + * base max-height cap with overflow fades. Summaries that contain any + * expandable child do not — capping then would put the child's own scroll + * body inside a scrolling parent, which is poor UX. The expandability test + * reuses `isWorkRowExpandable` so the cap rule and the per-row expand + * affordance can never disagree. + */ +export function isNonExpandableSummary( + children: readonly TimelineViewWorkRow[], +): boolean { + return ( + children.length > 0 && + children.every((child) => !isWorkRowExpandable(child)) + ); +} + +function shouldAutoExpandLiveFrontierRow(row: ThreadTimelineViewRow): boolean { + if (!isRowExpandable(row)) { + return false; + } + switch (row.kind) { + case "system": + return row.status === "pending"; + case "bundle-summary": + return true; + case "work": + return ( + row.workKind === "delegation" || + row.workKind === "image-view" || + // A running workflow auto-opens so live agent progress is visible. + (row.workKind === "workflow" && row.status === "pending") + ); + case "conversation": + case "step-summary": + case "turn": + return false; + default: + return assertNever(row); + } +} + +function shouldAutoExpandTerminalFrontierRow( + row: ThreadTimelineViewRow, +): boolean { + return ( + isRowExpandable(row) && row.kind === "system" && row.status === "error" + ); +} + +function visitForTerminalFrontierAutoExpand( + rows: readonly ThreadTimelineViewRow[], + ids: Set, +): void { + const tail = rows[rows.length - 1]; + if (tail && shouldAutoExpandTerminalFrontierRow(tail)) { + ids.add(tail.id); + } + + for (const row of rows) { + if ( + row.kind === "work" && + row.workKind === "delegation" && + row.status === "pending" + ) { + visitForTerminalFrontierAutoExpand(row.childRows, ids); + } + } +} + +// Auto-expand rule: +// +// 1. Terminal frontier: the literal tail row in a scope. Selected terminal +// rows, currently system errors with detail, open when they arrive. The +// terminal pass descends into pending delegation childRows as nested +// scopes. The row component preserves that visible disclosure state after +// later appends; the collector does not keep old terminal rows +// auto-expanded. +// +// 2. Live frontier: only while the scope is active, find the trailing row +// that the agent produced (skipping user input rows). Selected live rows +// open while they are the current active frontier, then stop being +// auto-expanded when newer agent/system/work output supersedes them. +// +// Active containers are the timeline's top-level row list (when the thread +// is active) and the childRows of pending delegations *inside an active +// container*. A completed delegation closes its scope, so a pending +// sub-delegation buried inside a completed parent does NOT auto-expand — +// the active scope must propagate from the top-level thread runtime down +// through every enclosing container. +function visitForLiveFrontierAutoExpand( + rows: readonly ThreadTimelineViewRow[], + scopeActive: boolean, + ids: Set, +): void { + if (!scopeActive) { + return; + } + const frontier = findTimelineFrontierRow(rows); + if (frontier && shouldAutoExpandLiveFrontierRow(frontier)) { + ids.add(frontier.id); + } + for (const row of rows) { + if ( + row.kind === "work" && + row.workKind === "delegation" && + row.status === "pending" + ) { + visitForLiveFrontierAutoExpand(row.childRows, true, ids); + } + } +} + +export function collectTimelineAutoExpansionRowIds({ + rows, + scopeActive, +}: CollectTimelineAutoExpansionRowIdsArgs): TimelineAutoExpansionRowIds { + const terminalFrontierRowIds = new Set(); + const liveFrontierRowIds = new Set(); + visitForTerminalFrontierAutoExpand(rows, terminalFrontierRowIds); + visitForLiveFrontierAutoExpand(rows, scopeActive, liveFrontierRowIds); + return { + liveFrontierRowIds, + terminalFrontierRowIds, + }; +} diff --git a/packages/client-core/src/timeline/timeline-merge.ts b/packages/client-core/src/timeline/timeline-merge.ts new file mode 100644 index 0000000000..c75e766700 --- /dev/null +++ b/packages/client-core/src/timeline/timeline-merge.ts @@ -0,0 +1,440 @@ +import type { + ThreadTimelineResponse, + TimelinePaginationCursor, + TimelineRow, +} from "@bb/server-contract"; +import { isOptimisticTimelineRowId } from "./optimistic-timeline-row.js"; + +export type ThreadTimelineRowFilter = (row: TimelineRow) => boolean; + +type NullableTimelinePaginationCursor = TimelinePaginationCursor | null; + +export interface LoadedTimelineState { + /** Inclusive end of the latest server window already merged into `rows`. */ + latestWindowEndSequence: number | null; + olderCursor: NullableTimelinePaginationCursor; + rows: TimelineRow[]; + surfaceKey: string; +} + +interface BuildLoadedTimelineStateArgs { + latestWindowEndSequence: number | null; + latestRows: TimelineRow[]; + olderCursor: NullableTimelinePaginationCursor; + surfaceKey: string; +} + +interface AreTimelinePaginationCursorsEqualArgs { + left: NullableTimelinePaginationCursor; + right: NullableTimelinePaginationCursor; +} + +export interface MergeLatestTimelineRowsArgs { + latestRows: readonly TimelineRow[]; + latestWindowStartSequence: number; + loadedRows: TimelineRow[]; +} + +interface MergeLatestTimelineRowsResult { + canMerge: boolean; + rows: TimelineRow[]; +} + +interface TimelineRowIdentityEntry { + row: TimelineRow; + signature: string; +} + +interface PreserveTimelineRowIdentityArgs { + nextRows: readonly TimelineRow[]; + previousRows: readonly TimelineRow[]; +} + +interface AreTimelineRowReferencesEqualArgs { + left: readonly TimelineRow[]; + right: readonly TimelineRow[]; +} + +export interface PrependOlderTimelineRowsArgs { + loadedRows: readonly TimelineRow[]; + olderRows: readonly TimelineRow[]; +} + +export interface MergeLoadedTimelineWithLatestArgs { + current: LoadedTimelineState; + latestTimeline: ThreadTimelineResponse; + surfaceKey: string; +} + +export interface RecoverLoadedTimelineAfterStaleCursorArgs { + current: LoadedTimelineState; + latestTimeline: ThreadTimelineResponse; + surfaceKey: string; +} + +export interface BuildSurfaceKeyArgs { + rowFilter: ThreadTimelineRowFilter | undefined; + surfaceKey: string | undefined; + threadId: string; +} + +export function buildSurfaceKey({ + rowFilter, + surfaceKey, + threadId, +}: BuildSurfaceKeyArgs): string { + if (surfaceKey !== undefined) { + return surfaceKey; + } + return rowFilter === undefined ? threadId : `${threadId}:filtered`; +} + +export function filterTimelineRows({ + rowFilter, + rows, +}: { + rowFilter: ThreadTimelineRowFilter | undefined; + rows: readonly TimelineRow[]; +}): TimelineRow[] { + return rowFilter === undefined ? [...rows] : rows.filter(rowFilter); +} + +export function filterThreadTimelineResponse({ + response, + rowFilter, +}: { + response: ThreadTimelineResponse; + rowFilter: ThreadTimelineRowFilter | undefined; +}): ThreadTimelineResponse { + if (rowFilter === undefined) { + return response; + } + return { + ...response, + rows: response.rows.filter(rowFilter), + }; +} + +export function buildLoadedTimelineState({ + latestWindowEndSequence, + latestRows, + olderCursor, + surfaceKey, +}: BuildLoadedTimelineStateArgs): LoadedTimelineState { + return { + latestWindowEndSequence, + olderCursor, + rows: latestRows, + surfaceKey, + }; +} + +export function areTimelinePaginationCursorsEqual({ + left, + right, +}: AreTimelinePaginationCursorsEqualArgs): boolean { + if (left === null || right === null) { + return left === right; + } + return left.anchorSeq === right.anchorSeq && left.anchorId === right.anchorId; +} + +function appendTimelineRowsPreservingOrder( + target: TimelineRow[], + rows: readonly TimelineRow[], +): void { + const seenIds = new Set(target.map((row) => row.id)); + for (const row of rows) { + if (seenIds.has(row.id)) { + continue; + } + seenIds.add(row.id); + target.push(row); + } +} + +function timelineRowIdentitySignature(row: TimelineRow): string { + return [ + row.kind, + row.id, + row.threadId, + row.turnId ?? "", + row.sourceSeqStart, + row.sourceSeqEnd, + row.startedAt, + row.createdAt, + ].join("\u001f"); +} + +function buildTimelineRowIdentityMap( + rows: readonly TimelineRow[], +): ReadonlyMap { + const rowsById = new Map(); + for (const row of rows) { + rowsById.set(row.id, { + row, + signature: timelineRowIdentitySignature(row), + }); + } + return rowsById; +} + +function preserveTimelineRowIdentity({ + nextRows, + previousRows, +}: PreserveTimelineRowIdentityArgs): TimelineRow[] { + const previousRowsById = buildTimelineRowIdentityMap(previousRows); + return nextRows.map((row) => { + const previous = previousRowsById.get(row.id); + if (previous && previous.signature === timelineRowIdentitySignature(row)) { + return previous.row; + } + return row; + }); +} + +function areTimelineRowReferencesEqual({ + left, + right, +}: AreTimelineRowReferencesEqualArgs): boolean { + if (left.length !== right.length) return false; + return left.every((row, index) => row === right[index]); +} + +export function prependOlderTimelineRows({ + loadedRows, + olderRows, +}: PrependOlderTimelineRowsArgs): TimelineRow[] { + const rows: TimelineRow[] = []; + appendTimelineRowsPreservingOrder(rows, olderRows); + appendTimelineRowsPreservingOrder(rows, loadedRows); + return rows; +} + +export function mergeLatestTimelineRows({ + latestRows, + latestWindowStartSequence, + loadedRows: retainedRows, +}: MergeLatestTimelineRowsArgs): MergeLatestTimelineRowsResult { + // Optimistic rows are carried by `latestRows` (they are written into the + // timeline cache) and disappear from it once the server's real row lands. + // Retaining a copy here would survive that swap: an id minted client-side + // never overlaps a server id, so the no-overlap branch below would append + // the server row *after* the stale optimistic one and the message would + // render twice. This is only observable when nothing else overlaps — a + // thread whose first message is being sent, e.g. a fresh side chat. + const loadedRows = retainedRows.some((row) => + isOptimisticTimelineRowId(row.id), + ) + ? retainedRows.filter((row) => !isOptimisticTimelineRowId(row.id)) + : retainedRows; + + const identityPreservedLatestRows = preserveTimelineRowIdentity({ + nextRows: latestRows, + previousRows: loadedRows, + }); + + if (loadedRows.length === 0) { + return { + canMerge: true, + rows: identityPreservedLatestRows, + }; + } + + const latestRowsById = new Map( + identityPreservedLatestRows.map((row) => [row.id, row]), + ); + // The latest response is authoritative only from its raw sequence boundary + // onward. Keep every older row regardless of its kind. A row crossing the + // boundary is kept only when the new projection carries the same identity, + // in which case its value is replaced in place below. + const rowsToRetain = loadedRows.filter( + (row) => + row.sourceSeqEnd < latestWindowStartSequence || + latestRowsById.has(row.id), + ); + const retainedRowIds = new Set(rowsToRetain.map((row) => row.id)); + const loadedCommonIds = rowsToRetain.flatMap((row) => + latestRowsById.has(row.id) ? [row.id] : [], + ); + const latestCommonIds = identityPreservedLatestRows.flatMap((row) => + retainedRowIds.has(row.id) ? [row.id] : [], + ); + if ( + loadedCommonIds.length !== latestCommonIds.length || + loadedCommonIds.some((id, index) => id !== latestCommonIds[index]) + ) { + // The two projections disagree about row order. There is no unambiguous + // splice, so the caller must rebuild from the authoritative latest page. + return { canMerge: false, rows: identityPreservedLatestRows }; + } + + // New latest rows belong immediately before their next shared row. This + // preserves the old position of a straddling row (and therefore older rows + // around it), while still honoring server order for newly projected rows. + const rowsBeforeSharedId = new Map(); + let pendingRows: TimelineRow[] = []; + for (const row of identityPreservedLatestRows) { + if (!retainedRowIds.has(row.id)) { + pendingRows.push(row); + continue; + } + if (pendingRows.length > 0) { + rowsBeforeSharedId.set(row.id, pendingRows); + pendingRows = []; + } + } + + const rows: TimelineRow[] = []; + for (const row of rowsToRetain) { + const rowsBefore = rowsBeforeSharedId.get(row.id); + if (rowsBefore) { + rows.push(...rowsBefore); + } + rows.push(latestRowsById.get(row.id) ?? row); + } + rows.push(...pendingRows); + if (areTimelineRowReferencesEqual({ left: loadedRows, right: rows })) { + return { + canMerge: true, + rows: loadedRows, + }; + } + + return { + canMerge: true, + rows, + }; +} + +/** + * First event sequence a window covers. Every pagination cursor names the first + * sequence the page that issued it covered — that is what makes older pages + * chain — so the cursor is the exact lower bound of the window it arrived with. + * No cursor means the page reached the start of the thread. + */ +function timelineWindowStartSequence(timeline: ThreadTimelineResponse): number { + return timeline.timelinePage.olderCursor?.anchorSeq ?? 0; +} + +/** + * Whether the fresh window continues the loaded one, in raw event sequences. + * + * Rows cannot answer this, in three separate ways: + * + * - Most events never become a row — `turn/completed`, token-usage and + * rate-limit updates — so the distance from the last loaded row to the next + * window is routinely non-zero while the history is in fact continuous. A + * follow-up submitted on a budgeted thread lands exactly here: the prompt + * opens the next window one sequence past a `turn/completed` that is not a + * row. + * - Rows are ordered by where they start, not where they end, so the last row + * is not the one that reaches furthest. A turn summary spans its whole turn + * while shorter rows that begin later sort after it. + * - A window's first row can start *below* the window, because the projection + * backfills a turn's `turn/started` row from under the cut. + * + * Each shape reports a break that is not there, and the caller answers a break + * by dropping every loaded page — the timeline visibly truncates to the newest + * window and refills as auto-load pages it back. The sequences the server + * states outright have none of these failure modes. + */ +function timelineWindowsAreContiguous( + current: LoadedTimelineState, + latestTimeline: ThreadTimelineResponse, +): boolean { + return ( + current.latestWindowEndSequence !== null && + latestTimeline.maxSeq >= current.latestWindowEndSequence && + timelineWindowStartSequence(latestTimeline) <= + current.latestWindowEndSequence + 1 + ); +} + +function mergeLoadedTimelineOlderCursor( + current: NullableTimelinePaginationCursor, + latest: NullableTimelinePaginationCursor, +): NullableTimelinePaginationCursor { + if (current === null || latest === null) { + return null; + } + return latest.anchorSeq <= current.anchorSeq ? latest : current; +} + +export function mergeLoadedTimelineWithLatest({ + current, + latestTimeline, + surfaceKey, +}: MergeLoadedTimelineWithLatestArgs): LoadedTimelineState { + if ( + current.surfaceKey !== surfaceKey || + !timelineWindowsAreContiguous(current, latestTimeline) + ) { + return buildLoadedTimelineState({ + latestWindowEndSequence: latestTimeline.maxSeq, + latestRows: latestTimeline.rows, + olderCursor: latestTimeline.timelinePage.olderCursor, + surfaceKey, + }); + } + + const latestMerge = mergeLatestTimelineRows({ + latestRows: latestTimeline.rows, + latestWindowStartSequence: timelineWindowStartSequence(latestTimeline), + loadedRows: current.rows, + }); + if (!latestMerge.canMerge) { + return buildLoadedTimelineState({ + latestWindowEndSequence: latestTimeline.maxSeq, + latestRows: latestTimeline.rows, + olderCursor: latestTimeline.timelinePage.olderCursor, + surfaceKey, + }); + } + + return { + ...current, + latestWindowEndSequence: latestTimeline.maxSeq, + olderCursor: mergeLoadedTimelineOlderCursor( + current.olderCursor, + latestTimeline.timelinePage.olderCursor, + ), + rows: latestMerge.rows, + }; +} + +export function recoverLoadedTimelineAfterStaleCursor({ + current, + latestTimeline, + surfaceKey, +}: RecoverLoadedTimelineAfterStaleCursorArgs): LoadedTimelineState { + if (current.surfaceKey !== surfaceKey) { + return buildLoadedTimelineState({ + latestWindowEndSequence: latestTimeline.maxSeq, + latestRows: latestTimeline.rows, + olderCursor: latestTimeline.timelinePage.olderCursor, + surfaceKey, + }); + } + + const latestMerge = mergeLatestTimelineRows({ + latestRows: latestTimeline.rows, + latestWindowStartSequence: timelineWindowStartSequence(latestTimeline), + loadedRows: current.rows, + }); + if (!latestMerge.canMerge) { + return buildLoadedTimelineState({ + latestWindowEndSequence: latestTimeline.maxSeq, + latestRows: latestTimeline.rows, + olderCursor: latestTimeline.timelinePage.olderCursor, + surfaceKey, + }); + } + + return { + latestWindowEndSequence: latestTimeline.maxSeq, + olderCursor: latestTimeline.timelinePage.olderCursor, + rows: latestMerge.rows, + surfaceKey, + }; +} diff --git a/packages/client-core/src/timeline/timelineRowSignatures.ts b/packages/client-core/src/timeline/timelineRowSignatures.ts new file mode 100644 index 0000000000..cfc15523b1 --- /dev/null +++ b/packages/client-core/src/timeline/timelineRowSignatures.ts @@ -0,0 +1,334 @@ +import type { TimelineActivityIntent } from "@bb/server-contract"; +import { + assertNever, + type ThreadTimelineViewRow, + type TimelineViewWorkRow, +} from "@bb/thread-view"; + +type TimelineRowSignaturePart = boolean | number | string | null | undefined; + +function signaturePart(value: TimelineRowSignaturePart): string { + if (value === null) return ""; + if (value === undefined) return ""; + return String(value); +} + +export function joinSignatureParts( + parts: readonly TimelineRowSignaturePart[], +): string { + return parts.map(signaturePart).join("\u001f"); +} + +function activityIntentSignature(intent: TimelineActivityIntent): string { + switch (intent.type) { + case "read": + return joinSignatureParts([ + intent.type, + intent.command, + intent.name, + intent.path, + ]); + case "list_files": + return joinSignatureParts([intent.type, intent.command, intent.path]); + case "search": + return joinSignatureParts([ + intent.type, + intent.command, + intent.query, + intent.path, + ]); + case "unknown": + return joinSignatureParts([intent.type, intent.command]); + default: + return assertNever(intent); + } +} + +function activityIntentsSignature( + intents: readonly TimelineActivityIntent[], +): string { + return intents.map(activityIntentSignature).join("\u001e"); +} + +// View rows are immutable — `useTimelineViewRowsCache` preserves row identity +// across renders for unchanged data — so signature computation is safe to +// memoize by row reference. A miss is the streaming-update path (new row); +// a hit covers all cross-render reuse, including duplicate invocations from +// `areTimelineRowViewPropsEqual`, `useTimelineRowTitleRenderState`, and +// `TimelineExpandableBody`'s `contentKey`. +const rowSignatureCache = new WeakMap(); +const rowsSignatureCache = new WeakMap< + readonly ThreadTimelineViewRow[], + string +>(); + +export function timelineRowsSignature( + rows: readonly ThreadTimelineViewRow[], +): string { + const cached = rowsSignatureCache.get(rows); + if (cached !== undefined) return cached; + const signature = rows.map(timelineRowRenderSignature).join("\u001e"); + rowsSignatureCache.set(rows, signature); + return signature; +} + +function timelineRowBaseSignature(row: ThreadTimelineViewRow): string { + // sourceSeqEnd guards high-mutation fields omitted from signatures below, + // including output, text, and diffs. In-place row content mutations must + // advance the source sequence to avoid stale memoized UI. + return joinSignatureParts([ + row.kind, + row.id, + row.threadId, + row.turnId, + row.sourceSeqStart, + row.sourceSeqEnd, + row.startedAt, + row.createdAt, + ]); +} + +function timelineWorkRowRenderSignature(row: TimelineViewWorkRow): string { + const baseParts: TimelineRowSignaturePart[] = [ + timelineRowBaseSignature(row), + row.status, + row.workKind, + row.inClosedStep, + ]; + + switch (row.workKind) { + case "command": + return joinSignatureParts([ + ...baseParts, + row.callId, + row.command, + row.source, + row.exitCode, + row.completedAt, + row.approvalStatus, + activityIntentsSignature(row.activityIntents), + ]); + case "tool": + return joinSignatureParts([ + ...baseParts, + row.callId, + row.toolName, + row.completedAt, + row.approvalStatus, + activityIntentsSignature(row.activityIntents), + ]); + case "file-change": + return joinSignatureParts([ + ...baseParts, + row.callId, + row.approvalStatus, + row.change.kind, + row.change.path, + row.change.movePath, + row.change.diffStats.added, + row.change.diffStats.removed, + ]); + case "web-search": + return joinSignatureParts([ + ...baseParts, + row.callId, + row.queries.join("\u001e"), + row.completedAt, + ]); + case "web-fetch": + return joinSignatureParts([ + ...baseParts, + row.callId, + row.url, + row.prompt, + row.pattern, + row.completedAt, + ]); + case "image-view": + return joinSignatureParts([ + ...baseParts, + row.callId, + row.path, + row.completedAt, + ]); + case "delegation": + return joinSignatureParts([ + ...baseParts, + row.callId, + row.toolName, + row.subagentType, + row.description, + row.completedAt, + timelineRowsSignature(row.childRows), + ]); + case "workflow": + return joinSignatureParts([ + ...baseParts, + row.itemId, + row.taskType, + row.taskStatus, + row.workflowName, + row.description, + row.completedAt, + row.summary, + row.error, + row.usage?.totalTokens ?? null, + // Every progress-mutated agent field must break memo equality. + row.workflow + ? row.workflow.agents + .map((agent) => + joinSignatureParts([ + agent.index, + agent.label, + agent.state, + agent.attempt, + agent.tokens ?? null, + agent.toolCalls ?? null, + agent.durationMs ?? null, + agent.lastProgressAt, + agent.error ?? null, + ]), + ) + .join("\u001e") + : null, + row.workflow + ? row.workflow.phases + .map((phase) => + joinSignatureParts([ + phase.index, + phase.title, + phase.kind ?? null, + ]), + ) + .join("\u001e") + : null, + ]); + case "approval": + return joinSignatureParts([ + ...baseParts, + row.interactionId, + row.approvalKind, + row.lifecycle, + row.approvalKind === "permission-grant" ? row.grantScope : null, + row.approvalKind === "permission-grant" ? row.statusReason : null, + row.target.itemId, + row.target.toolName, + ]); + case "question": + return joinSignatureParts([ + ...baseParts, + row.interactionId, + row.lifecycle, + row.statusReason, + row.questions + .map((question) => + joinSignatureParts([ + question.id, + question.prompt, + question.shortLabel, + question.multiSelect, + question.allowFreeText, + question.options + ?.map((option) => + joinSignatureParts([ + option.value, + option.label, + option.description, + ]), + ) + .join("\u001d"), + ]), + ) + .join("\u001e"), + row.answers + ? Object.entries(row.answers) + .map(([questionId, answer]) => + joinSignatureParts([ + questionId, + answer.selected.join("\u001d"), + answer.freeText, + ]), + ) + .join("\u001e") + : null, + ]); + default: + return assertNever(row); + } +} + +export function timelineRowRenderSignature(row: ThreadTimelineViewRow): string { + const cached = rowSignatureCache.get(row); + if (cached !== undefined) return cached; + const signature = computeTimelineRowRenderSignature(row); + rowSignatureCache.set(row, signature); + return signature; +} + +function computeTimelineRowRenderSignature(row: ThreadTimelineViewRow): string { + const baseSignature = timelineRowBaseSignature(row); + switch (row.kind) { + case "conversation": + return joinSignatureParts([ + baseSignature, + row.role, + row.turnRequest?.kind, + row.turnRequest?.status, + row.attachments?.localFiles, + row.attachments?.localImages, + row.attachments?.webImages, + ]); + case "system": + if (row.systemKind === "operation") { + return joinSignatureParts([ + baseSignature, + row.status, + row.systemKind, + row.operationKind, + row.operationKind === "parent-change" + ? row.parentChange.action + : null, + row.operationKind === "parent-change" + ? row.parentChange.previousParentThreadId + : null, + row.operationKind === "parent-change" + ? row.parentChange.previousParentThreadTitle + : null, + row.operationKind === "parent-change" + ? row.parentChange.nextParentThreadId + : null, + row.operationKind === "parent-change" + ? row.parentChange.nextParentThreadTitle + : null, + row.title, + row.detail, + ]); + } + return joinSignatureParts([ + baseSignature, + row.status, + row.systemKind, + row.title, + row.detail, + ]); + case "bundle-summary": + case "step-summary": + return joinSignatureParts([ + baseSignature, + row.status, + timelineRowsSignature(row.children), + ]); + case "turn": + return joinSignatureParts([ + baseSignature, + row.status, + row.summaryCount, + row.completedAt, + row.children ? timelineRowsSignature(row.children) : null, + ]); + case "work": + return timelineWorkRowRenderSignature(row); + default: + return assertNever(row); + } +} diff --git a/apps/app/src/components/promptbox/mentions/command-trigger.test.ts b/packages/client-core/test/command-trigger.test.ts similarity index 96% rename from apps/app/src/components/promptbox/mentions/command-trigger.test.ts rename to packages/client-core/test/command-trigger.test.ts index ce9319e711..51054c4b6b 100644 --- a/apps/app/src/components/promptbox/mentions/command-trigger.test.ts +++ b/packages/client-core/test/command-trigger.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { buildProviderPromptActionProps, commandPillDismissedRangeEnd, -} from "./command-trigger"; +} from "../src/prompt/mentions/command-trigger.js"; describe("buildProviderPromptActionProps", () => { it("maps skills and insertion composer actions into prompt action props", () => { diff --git a/apps/app/src/components/thread/timeline/compute-muted-prefix-length.test.ts b/packages/client-core/test/compute-muted-prefix-length.test.ts similarity index 94% rename from apps/app/src/components/thread/timeline/compute-muted-prefix-length.test.ts rename to packages/client-core/test/compute-muted-prefix-length.test.ts index 1ef0571545..391fced160 100644 --- a/apps/app/src/components/thread/timeline/compute-muted-prefix-length.test.ts +++ b/packages/client-core/test/compute-muted-prefix-length.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { computeMutedPrefixLength } from "./compute-muted-prefix-length"; +import { computeMutedPrefixLength } from "../src/timeline/compute-muted-prefix-length.js"; describe("computeMutedPrefixLength", () => { it("returns 0 for user-initiated text", () => { diff --git a/apps/app/src/components/promptbox/effective-prompt-mode.test.ts b/packages/client-core/test/effective-prompt-mode.test.ts similarity index 98% rename from apps/app/src/components/promptbox/effective-prompt-mode.test.ts rename to packages/client-core/test/effective-prompt-mode.test.ts index 0493aedc6a..ae6dfff0a2 100644 --- a/apps/app/src/components/promptbox/effective-prompt-mode.test.ts +++ b/packages/client-core/test/effective-prompt-mode.test.ts @@ -4,7 +4,7 @@ import { permissionDisplayForPromptMode, shouldDisablePermissionPickerForActivePromptMode, shouldDisablePermissionPickerForPromptMode, -} from "./effective-prompt-mode"; +} from "../src/prompt/effective-prompt-mode.js"; const planCommandMention = { start: 0, diff --git a/apps/app/src/lib/file-preview.test.ts b/packages/client-core/test/file-preview.test.ts similarity index 99% rename from apps/app/src/lib/file-preview.test.ts rename to packages/client-core/test/file-preview.test.ts index 3f77773d82..e001df841d 100644 --- a/apps/app/src/lib/file-preview.test.ts +++ b/packages/client-core/test/file-preview.test.ts @@ -5,7 +5,7 @@ import { isCsvFilePreview, isMarkdownFilePreview, normalizeFilePreviewMimeType, -} from "./file-preview"; +} from "../src/file-preview.js"; describe("file-preview", () => { it("builds text previews from declared text mime types or detected UTF-8 content", () => { diff --git a/apps/app/src/components/promptbox/mentions/find-active-trigger.test.ts b/packages/client-core/test/find-active-trigger.test.ts similarity index 97% rename from apps/app/src/components/promptbox/mentions/find-active-trigger.test.ts rename to packages/client-core/test/find-active-trigger.test.ts index aa45cb15f7..41c5b48388 100644 --- a/apps/app/src/components/promptbox/mentions/find-active-trigger.test.ts +++ b/packages/client-core/test/find-active-trigger.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { findActiveTrigger } from "./find-active-trigger"; +import { findActiveTrigger } from "../src/prompt/mentions/find-active-trigger.js"; function editorWithText( text: string, diff --git a/apps/app/src/lib/fork-thread-request.test.ts b/packages/client-core/test/fork-thread-request.test.ts similarity index 98% rename from apps/app/src/lib/fork-thread-request.test.ts rename to packages/client-core/test/fork-thread-request.test.ts index a5e43df4bb..bc15fe9309 100644 --- a/apps/app/src/lib/fork-thread-request.test.ts +++ b/packages/client-core/test/fork-thread-request.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest"; import { buildForkThreadRequest, isThreadForkable, -} from "./fork-thread-request"; +} from "../src/prompt/fork-thread-request.js"; function makeThread(overrides: Partial = {}): Thread { const base: Thread = { diff --git a/apps/app/src/lib/localhost-link-rewrite-preference.test.ts b/packages/client-core/test/localhost-link-rewrite.test.ts similarity index 94% rename from apps/app/src/lib/localhost-link-rewrite-preference.test.ts rename to packages/client-core/test/localhost-link-rewrite.test.ts index c171567dbf..ec7ab5884e 100644 --- a/apps/app/src/lib/localhost-link-rewrite-preference.test.ts +++ b/packages/client-core/test/localhost-link-rewrite.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { rewriteLocalhostLinkHref } from "./localhost-link-rewrite-preference"; +import { rewriteLocalhostLinkHref } from "../src/localhost-link-rewrite.js"; describe("rewriteLocalhostLinkHref", () => { it("rewrites localhost and 127.0.0.1 http links to the current page hostname", () => { diff --git a/apps/app/src/components/sidebar/machineThreadGroups.test.ts b/packages/client-core/test/machineThreadGroups.test.ts similarity index 98% rename from apps/app/src/components/sidebar/machineThreadGroups.test.ts rename to packages/client-core/test/machineThreadGroups.test.ts index b36de6a044..2d748dfbe2 100644 --- a/apps/app/src/components/sidebar/machineThreadGroups.test.ts +++ b/packages/client-core/test/machineThreadGroups.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest"; import { buildMachineThreadGroups, NO_MACHINE_GROUP_KEY, -} from "./machineThreadGroups"; +} from "../src/sidebar/machineThreadGroups.js"; function createThread(overrides: Partial): ThreadListEntry { return { diff --git a/packages/client-core/test/no-dom.test.ts b/packages/client-core/test/no-dom.test.ts new file mode 100644 index 0000000000..df2e78f5af --- /dev/null +++ b/packages/client-core/test/no-dom.test.ts @@ -0,0 +1,113 @@ +import { readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const SRC_DIR = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../src", +); + +function listSourceFiles(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) return listSourceFiles(fullPath); + return /\.tsx?$/u.test(entry.name) ? [fullPath] : []; + }); +} + +function stripComments(source: string): string { + return source + .replace(/\/\*[\s\S]*?\*\//gu, "") + .replace(/(^|[^:"'`])\/\/.*$/gmu, "$1"); +} + +/** + * Browser globals a shared module must not reach for, in any of the shapes a + * "safe" probe tends to take: bare (`location.href`), hung off a host object + * (`globalThis.window?.location`, `self.navigator`), `typeof` checks, + * `"localStorage" in globalThis`, or `window as Window`. `typeof window` + * guards count too: the native runtime has no window and the code path would + * be dead there anyway, so the module belongs in `apps/app`. + */ +const BROWSER_GLOBALS = + "window|document|localStorage|sessionStorage|navigator|location|history|matchMedia|requestAnimationFrame"; +const GLOBAL_HOSTS = "globalThis|self|window"; +const BROWSER_GLOBAL_PATTERN = new RegExp( + [ + // `window.x`, `window?.x`, `window!.x`, `window[`, `window(`, `window;`, + // `window ?? x`, `window && x`, `window === x`, `window as Window`, and a + // bare `window` at end of line — optionally behind a `globalThis.`/`self.` + // host. A trailing `:` (object key, type member) or quote stays allowed. + String.raw`(? { + const files = listSourceFiles(SRC_DIR); + + it("has source files to check", () => { + expect(files.length).toBeGreaterThan(0); + }); + + it.each(files.map((file) => [path.relative(SRC_DIR, file), file]))( + "%s does not reference browser globals or UI-framework imports", + (_label, file) => { + const source = stripComments(readFileSync(file, "utf8")); + const globalMatch = BROWSER_GLOBAL_PATTERN.exec(source); + expect( + globalMatch?.[0] ?? null, + `browser global reference: ${globalMatch?.[0] ?? ""}`, + ).toBeNull(); + const importMatch = FORBIDDEN_IMPORT_PATTERN.exec(source); + expect( + importMatch?.[0] ?? null, + `forbidden import: ${importMatch?.[0] ?? ""}`, + ).toBeNull(); + }, + ); + + it("catches a window reference (self-check)", () => { + const caught = [ + "const w = window.location;", + 'if (typeof window === "undefined")', + "localStorage.getItem(key)", + "const host = globalThis.window?.location.hostname ?? null;", + 'if (typeof globalThis.localStorage !== "undefined")', + 'if ("localStorage" in globalThis)', + "self.navigator.userAgent", + "window?.location", + "const w = window as Window;", + "const w = window ?? null;", + "location.href", + "history.pushState(null, '', url);", + 'matchMedia("(prefers-color-scheme: dark)")', + "requestAnimationFrame(() => {});", + ]; + for (const snippet of caught) { + expect(BROWSER_GLOBAL_PATTERN.test(snippet), snippet).toBe(true); + } + expect( + FORBIDDEN_IMPORT_PATTERN.test( + 'import { matchPath } from "react-router-dom";', + ), + ).toBe(true); + const allowed = [ + "const windowed = true;", + "row.document.id", + "this.history.push(entry)", + "type Row = { location: string; history: Entry[] }", + "return { history: rows, location: at };", + 'kind: "document",', + ]; + for (const snippet of allowed) { + expect(BROWSER_GLOBAL_PATTERN.test(snippet), snippet).toBe(false); + } + }); +}); diff --git a/apps/app/src/components/sidebar/pinnedSidebarThreads.test.ts b/packages/client-core/test/pinnedSidebarThreads.test.ts similarity index 98% rename from apps/app/src/components/sidebar/pinnedSidebarThreads.test.ts rename to packages/client-core/test/pinnedSidebarThreads.test.ts index 65ccd5beda..927caf887b 100644 --- a/apps/app/src/components/sidebar/pinnedSidebarThreads.test.ts +++ b/packages/client-core/test/pinnedSidebarThreads.test.ts @@ -1,6 +1,6 @@ import type { ThreadListEntry } from "@bb/domain"; import { describe, expect, it } from "vitest"; -import { buildPinnedSidebarState } from "./pinnedSidebarThreads"; +import { buildPinnedSidebarState } from "../src/sidebar/pinnedSidebarThreads.js"; type ThreadListEntryOverrides = Partial; diff --git a/apps/app/src/components/sidebar/projectThreadGroups.test.ts b/packages/client-core/test/projectThreadGroups.test.ts similarity index 99% rename from apps/app/src/components/sidebar/projectThreadGroups.test.ts rename to packages/client-core/test/projectThreadGroups.test.ts index bd6b175c62..7f406aa4cb 100644 --- a/apps/app/src/components/sidebar/projectThreadGroups.test.ts +++ b/packages/client-core/test/projectThreadGroups.test.ts @@ -11,7 +11,7 @@ import { type ProjectThreadItem, type ProjectThreadNode, type ThreadComparator, -} from "./projectThreadGroups"; +} from "../src/sidebar/projectThreadGroups.js"; type ThreadListEntryOverrides = Partial; type TreeSummary = diff --git a/apps/app/src/lib/prompt-draft.test.ts b/packages/client-core/test/prompt-draft.test.ts similarity index 99% rename from apps/app/src/lib/prompt-draft.test.ts rename to packages/client-core/test/prompt-draft.test.ts index f65f10abb4..78dccc1e68 100644 --- a/apps/app/src/lib/prompt-draft.test.ts +++ b/packages/client-core/test/prompt-draft.test.ts @@ -8,7 +8,7 @@ import { parsePromptDraftStorage, promptDraftToInput, promptInputToDraft, -} from "./prompt-draft"; +} from "../src/prompt/prompt-draft.js"; const AUTOMATION_COMMAND_RESOURCE: PromptMentionResource = { kind: "command", diff --git a/apps/app/src/components/sidebar/sidebarSectionOrder.test.ts b/packages/client-core/test/sidebarSectionOrder.test.ts similarity index 98% rename from apps/app/src/components/sidebar/sidebarSectionOrder.test.ts rename to packages/client-core/test/sidebarSectionOrder.test.ts index 1b782897d2..775de13ef7 100644 --- a/apps/app/src/components/sidebar/sidebarSectionOrder.test.ts +++ b/packages/client-core/test/sidebarSectionOrder.test.ts @@ -3,7 +3,7 @@ import { buildSidebarEntitySectionId, normalizeSidebarSectionOrder, reorderSidebarSectionOrder, -} from "./sidebarSectionOrder"; +} from "../src/sidebar/sidebarSectionOrder.js"; describe("normalizeSidebarSectionOrder", () => { const projectA = buildSidebarEntitySectionId("project", "a"); diff --git a/apps/app/src/components/thread/terminal/terminal-websocket-transport.test.ts b/packages/client-core/test/terminal-websocket-transport.test.ts similarity index 70% rename from apps/app/src/components/thread/terminal/terminal-websocket-transport.test.ts rename to packages/client-core/test/terminal-websocket-transport.test.ts index 78f81edd81..d087c3477f 100644 --- a/apps/app/src/components/thread/terminal/terminal-websocket-transport.test.ts +++ b/packages/client-core/test/terminal-websocket-transport.test.ts @@ -7,10 +7,10 @@ import { TerminalWebSocketTransport, type TerminalBrowserSocket, type TerminalSocketConnectionState, -} from "./terminal-websocket-transport"; +} from "../src/terminal/terminal-websocket-transport.js"; class FakeTerminalBrowserSocket implements TerminalBrowserSocket { - bufferedAmount = 0; + bufferedAmount: number | undefined = 0; closeCalls: Array<{ code?: number; reason?: string }> = []; onclose: ((event: CloseEvent) => void) | null = null; onerror: ((event: Event) => void) | null = null; @@ -227,6 +227,30 @@ describe("TerminalWebSocketTransport", () => { harness.transport.dispose(); }); + it("flushes input when the socket never reports bufferedAmount (React Native)", () => { + vi.useFakeTimers(); + const harness = createHarness({ + drainPollMs: 10, + socketHighWaterBytes: 8, + }); + const queuedInput = Buffer.from("queued").toString("base64"); + const liveInput = Buffer.from("live").toString("base64"); + + harness.transport.start(); + const socket = harness.sockets[0]!; + // React Native's WebSocket has no bufferedAmount property at all. + socket.bufferedAmount = undefined; + expect(harness.transport.sendInput(queuedInput)).toBe(true); + socket.open(); + expect(inputMessages(socket)).toEqual([queuedInput]); + + expect(harness.transport.sendInput(liveInput)).toBe(true); + expect(inputMessages(socket)).toEqual([queuedInput, liveInput]); + vi.advanceTimersByTime(50); + expect(inputMessages(socket)).toEqual([queuedInput, liveInput]); + harness.transport.dispose(); + }); + it("reports bounded input queue overflow instead of silently dropping", () => { const harness = createHarness({ inputQueueMaxBytes: 3 }); harness.transport.start(); @@ -272,4 +296,73 @@ describe("TerminalWebSocketTransport", () => { }); harness.transport.dispose(); }); + it("suspends without reconnecting and resumes from the last seen chunk", () => { + vi.useFakeTimers(); + const harness = createHarness(); + const output = (seq: number): TerminalServerMessage => ({ + type: "output", + chunk: { + seq, + dataBase64: Buffer.from(String(seq)).toString("base64"), + }, + }); + + harness.transport.start(); + const first = harness.sockets[0]!; + first.open(); + first.receive(output(0)); + first.receive(output(1)); + + harness.transport.suspend(); + expect(first.closeCalls).toEqual([{ code: 1000, reason: "suspended" }]); + expect(harness.states.at(-1)).toBe("closed"); + // No reconnect while suspended, even past every backoff delay. + vi.advanceTimersByTime(10_000); + expect(harness.sockets).toHaveLength(1); + // Input typed while suspended waits for the next socket. + const queued = Buffer.from("ls\n").toString("base64"); + expect(harness.transport.sendInput(queued)).toBe(true); + + harness.transport.resume(); + expect(harness.sockets).toHaveLength(2); + expect(harness.urls[1]).toBe( + "ws://example.test/ws/terminals/term-1?sinceSeq=2", + ); + harness.sockets[1]!.open(); + expect(inputMessages(harness.sockets[1]!)).toEqual([queued]); + expect(harness.states.at(-1)).toBe("open"); + + // Resume is idempotent and suspend before start never opens a socket. + harness.transport.resume(); + expect(harness.sockets).toHaveLength(2); + harness.transport.dispose(); + }); + + it("does not open a socket on resume when the transport was never started", () => { + const harness = createHarness(); + harness.transport.suspend(); + harness.transport.resume(); + expect(harness.sockets).toHaveLength(0); + harness.transport.start(); + expect(harness.sockets).toHaveLength(1); + harness.transport.dispose(); + }); + + it("defers a start while suspended until resume", () => { + const harness = createHarness(); + harness.transport.suspend(); + harness.transport.start(); + expect(harness.sockets).toHaveLength(0); + expect(harness.states).not.toContain("connecting"); + + harness.transport.resume(); + expect(harness.sockets).toHaveLength(1); + expect(harness.urls[0]).toBe( + "ws://example.test/ws/terminals/term-1?sinceSeq=0", + ); + expect(harness.states.at(-1)).toBe("reconnecting"); + harness.sockets[0]!.open(); + expect(harness.states.at(-1)).toBe("open"); + harness.transport.dispose(); + }); }); diff --git a/apps/app/src/lib/thread-activity.test.ts b/packages/client-core/test/thread-activity.test.ts similarity index 99% rename from apps/app/src/lib/thread-activity.test.ts rename to packages/client-core/test/thread-activity.test.ts index 119b16d7cd..878ed6a477 100644 --- a/apps/app/src/lib/thread-activity.test.ts +++ b/packages/client-core/test/thread-activity.test.ts @@ -5,7 +5,7 @@ import { isUnreadDoneThread, resolveThreadListIndicator, type ThreadListIndicatorState, -} from "./thread-activity"; +} from "../src/thread/thread-activity.js"; type ChildActivityInput = Parameters< typeof getCollapsedChildActivity diff --git a/apps/app/src/lib/thread-handoff-request.test.ts b/packages/client-core/test/thread-handoff-request.test.ts similarity index 97% rename from apps/app/src/lib/thread-handoff-request.test.ts rename to packages/client-core/test/thread-handoff-request.test.ts index 08bbea90b7..93860a8508 100644 --- a/apps/app/src/lib/thread-handoff-request.test.ts +++ b/packages/client-core/test/thread-handoff-request.test.ts @@ -5,7 +5,7 @@ import { readThreadHandoffCreateSeedFromLocationState, THREAD_HANDOFF_CREATE_SEED_LOCATION_STATE_KEY, type ThreadHandoffCreateSeed, -} from "./thread-handoff-request"; +} from "../src/prompt/thread-handoff-request.js"; const SEED: ThreadHandoffCreateSeed = { environmentId: "env_source", diff --git a/apps/app/src/views/thread-detail/threadDetailPromptSubmission.test.ts b/packages/client-core/test/threadDetailPromptSubmission.test.ts similarity index 99% rename from apps/app/src/views/thread-detail/threadDetailPromptSubmission.test.ts rename to packages/client-core/test/threadDetailPromptSubmission.test.ts index 1f016a1183..05b082dfe0 100644 --- a/apps/app/src/views/thread-detail/threadDetailPromptSubmission.test.ts +++ b/packages/client-core/test/threadDetailPromptSubmission.test.ts @@ -9,7 +9,7 @@ import { canSubmitFollowUpShortcut, resolveDefaultExecutionOptionsState, shouldQueueFollowUpMessage, -} from "./threadDetailPromptSubmission"; +} from "../src/prompt/threadDetailPromptSubmission.js"; const textInput: PromptInput[] = [ { type: "text", text: "Follow up", mentions: [] }, diff --git a/apps/app/src/views/thread-detail/threadQueuedMessages.test.ts b/packages/client-core/test/threadQueuedMessages.test.ts similarity index 98% rename from apps/app/src/views/thread-detail/threadQueuedMessages.test.ts rename to packages/client-core/test/threadQueuedMessages.test.ts index 878ca867d5..0f9dad620b 100644 --- a/apps/app/src/views/thread-detail/threadQueuedMessages.test.ts +++ b/packages/client-core/test/threadQueuedMessages.test.ts @@ -3,7 +3,7 @@ import type { PromptInput } from "@bb/domain"; import { formatQueuedMessagePreview, queuedInputToDraft, -} from "./threadQueuedMessages"; +} from "../src/prompt/threadQueuedMessages.js"; describe("threadQueuedMessages", () => { it("formats queued-message previews from text or attachment-only inputs", () => { diff --git a/apps/app/src/components/sidebar/threadReadState.test.ts b/packages/client-core/test/threadReadState.test.ts similarity index 90% rename from apps/app/src/components/sidebar/threadReadState.test.ts rename to packages/client-core/test/threadReadState.test.ts index 74e3fb01b5..cb1ad9ea32 100644 --- a/apps/app/src/components/sidebar/threadReadState.test.ts +++ b/packages/client-core/test/threadReadState.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { getThreadReadToggleAction } from "./threadReadState"; +import { getThreadReadToggleAction } from "../src/sidebar/threadReadState.js"; describe("getThreadReadToggleAction", () => { it("marks a thread read when it has never been read", () => { @@ -28,5 +28,4 @@ describe("getThreadReadToggleAction", () => { }), ).toBe("mark_unread"); }); - }); diff --git a/apps/app/src/components/thread/timeline/useThreadTimelineController.merge.test.ts b/packages/client-core/test/timeline-merge.test.ts similarity index 99% rename from apps/app/src/components/thread/timeline/useThreadTimelineController.merge.test.ts rename to packages/client-core/test/timeline-merge.test.ts index 538bd74d83..dad13085d2 100644 --- a/apps/app/src/components/thread/timeline/useThreadTimelineController.merge.test.ts +++ b/packages/client-core/test/timeline-merge.test.ts @@ -13,7 +13,7 @@ import { prependOlderTimelineRows, recoverLoadedTimelineAfterStaleCursor, type LoadedTimelineState, -} from "./useThreadTimelineController"; +} from "../src/timeline/timeline-merge.js"; interface TimelineTestRowArgs { endSequence?: number; diff --git a/packages/client-core/tsconfig.json b/packages/client-core/tsconfig.json new file mode 100644 index 0000000000..1222880bbe --- /dev/null +++ b/packages/client-core/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": [ + "@bb/tsconfig/base.json", + "@bb/tsconfig/typecheck-overrides.json" + ], + "compilerOptions": { + "rootDir": ".", + "types": ["node"] + }, + "include": ["src", "test"] +} diff --git a/packages/client-core/vitest.config.ts b/packages/client-core/vitest.config.ts new file mode 100644 index 0000000000..d5e14c37c3 --- /dev/null +++ b/packages/client-core/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineWorkspaceTestConfig } from "../../vitest.shared.js"; + +export default defineWorkspaceTestConfig({ + test: { + silent: "passed-only", + name: "@bb/client-core", + environment: "node", + include: ["test/**/*.test.ts"], + exclude: ["dist/**", "node_modules/**"], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 82e83dfd78..302eb7ed4d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,6 +50,9 @@ importers: apps/app: dependencies: + '@bb/client-core': + specifier: workspace:* + version: link:../../packages/client-core '@bb/config': specifier: workspace:* version: link:../../packages/config @@ -1356,6 +1359,40 @@ importers: specifier: ^4.1.1 version: 4.1.1(@opentelemetry/api@1.9.1)(@types/node@22.19.10)(jsdom@29.0.1(@noble/hashes@2.0.1))(msw@2.12.14(@types/node@22.19.10)(@typescript/typescript6@6.0.2))(vite@8.0.12(@types/node@22.19.10)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + packages/client-core: + dependencies: + '@bb/core-ui': + specifier: workspace:* + version: link:../core-ui + '@bb/desktop-contract': + specifier: workspace:* + version: link:../desktop-contract + '@bb/domain': + specifier: workspace:* + version: link:../domain + '@bb/server-contract': + specifier: workspace:* + version: link:../server-contract + '@bb/thread-view': + specifier: workspace:* + version: link:../thread-view + zod: + specifier: 4.3.6 + version: 4.3.6 + devDependencies: + '@bb/tsconfig': + specifier: workspace:* + version: link:../tsconfig + '@types/node': + specifier: ^22.0.0 + version: 22.19.10 + typescript: + specifier: npm:@typescript/typescript6@^6.0.2 + version: '@typescript/typescript6@6.0.2' + typescript-7: + specifier: npm:typescript@^7.0.2 + version: typescript@7.0.2 + packages/config: dependencies: '@bb/domain':