diff --git a/apps/web/e2e/harness.ts b/apps/web/e2e/harness.ts index 1aa6b9f7e2dd..bd4c2083fac1 100644 --- a/apps/web/e2e/harness.ts +++ b/apps/web/e2e/harness.ts @@ -163,7 +163,10 @@ async function startIsolatedWebAppUnsafe( const page = await context.newPage(); resources.page = page; page.setDefaultTimeout(15_000); - await page.goto(pairingUrl, { waitUntil: "domcontentloaded" }); + // The first navigation lands on "Bundling in progress" whenever the dev + // server is cold, which outlasts the 15s default on a loaded machine. The + // redirect below already budgets 120s; the initial hop needs the same. + await page.goto(pairingUrl, { waitUntil: "domcontentloaded", timeout: 120_000 }); await page.waitForURL((url) => !url.pathname.startsWith("/pair"), { timeout: 120_000 }); const appReady = await waitForAppReady(page); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6d60039964b4..9ff890d653a8 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -125,6 +125,7 @@ import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import { isCommandPaletteOpen } from "../commandPaletteBus"; import { isComputerViewOpen, toggleComputerView } from "../computerViewBus"; import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; +import { formatGoalStatusMessage, parseGoalComposerCommand } from "@t3tools/shared/composerTrigger"; import { useMediaQuery } from "../hooks/useMediaQuery"; import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout"; import { @@ -5425,6 +5426,116 @@ function ChatViewContent(props: ChatViewProps) { composerPreviewAnnotations.length + composerReviewComments.length, }); + const goalCommand = parseGoalComposerCommand(trimmed); + let pendingGoalObjective: string | null = null; + if (goalCommand !== null && !directAnnotation) { + // Goal commands own only the prompt text: images, contexts, annotations, + // and review comments attached to the draft must survive a /goal submit. + const clearGoalComposer = () => { + promptRef.current = ""; + setComposerDraftPrompt(composerDraftTarget, ""); + composerRef.current?.resetCursorState(); + }; + const reportGoalCommandFailure = (result: AtomCommandResult): boolean => { + const succeeded = result._tag === "Success"; + if (!succeeded && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + setThreadError( + activeThread.id, + error instanceof Error ? error.message : "Failed to update the Objective.", + ); + } + return succeeded; + }; + if (goalCommand.action === "status") { + toastManager.add( + stackedThreadToast({ + type: "info", + title: "Objective", + description: formatGoalStatusMessage(activeThread.goal ?? null), + }), + ); + clearGoalComposer(); + return; + } + if (goalCommand.action === "refuse") { + toastManager.add( + stackedThreadToast({ + type: "warning", + title: "That command was not sent", + description: "Type /goal followed by the outcome to set an Objective.", + }), + ); + clearGoalComposer(); + return; + } + // Local drafts must pass the same gate: without it the objective would + // be sent as a normal message and the later setGoal call would fail. + if (!supportsGoal) { + toastManager.add( + stackedThreadToast({ + type: "warning", + title: "This environment cannot set an Objective", + description: "Update the server to use /goal.", + }), + ); + clearGoalComposer(); + return; + } + if ( + goalCommand.action === "clear" || + goalCommand.action === "pause" || + goalCommand.action === "resume" + ) { + if (!isServerThread) { + toastManager.add( + stackedThreadToast({ + type: "info", + title: "Objective", + description: "Set an Objective with /goal before pausing, resuming, or clearing.", + }), + ); + clearGoalComposer(); + return; + } + const result = + goalCommand.action === "clear" + ? await clearGoal({ + environmentId, + input: { threadId: activeThread.id }, + }) + : goalCommand.action === "pause" + ? await pauseGoal({ + environmentId, + input: { threadId: activeThread.id }, + }) + : await resumeGoal({ + environmentId, + input: { threadId: activeThread.id }, + }); + // Only a successful command consumes the draft; failures keep the + // text so the user can retry. + if (reportGoalCommandFailure(result)) { + clearGoalComposer(); + } + return; + } + if (isServerThread) { + const result = await setGoal({ + environmentId, + input: { + threadId: activeThread.id, + objective: goalCommand.objective, + messageId: newMessageId(), + }, + }); + if (reportGoalCommandFailure(result)) { + clearGoalComposer(); + } + return; + } + pendingGoalObjective = goalCommand.objective; + } if (!directAnnotation && showPlanFollowUpPrompt && activeProposedPlan) { const followUp = resolvePlanFollowUpSubmission({ draftText: trimmed, @@ -5515,7 +5626,10 @@ function ChatViewContent(props: ChatViewProps) { const composerPreviewAnnotationsSnapshot = [...composerPreviewAnnotations]; const composerReviewCommentsSnapshot: ReviewCommentContext[] = [...composerReviewComments]; const messageTextWithContexts = appendElementContextsToPrompt( - appendTerminalContextsToPrompt(promptForSend, composerTerminalContextsSnapshot), + appendTerminalContextsToPrompt( + pendingGoalObjective ?? promptForSend, + composerTerminalContextsSnapshot, + ), composerElementContextsSnapshot, ); const messageTextWithPreviewAnnotations = composerPreviewAnnotationsSnapshot.reduce( @@ -5627,7 +5741,7 @@ function ChatViewContent(props: ChatViewProps) { firstComposerImageName = firstComposerImage.name; } } - let titleSeed = trimmed; + let titleSeed = pendingGoalObjective ?? trimmed; if (!titleSeed) { if (firstComposerImageName) { titleSeed = `Image: ${firstComposerImageName}`; @@ -5738,6 +5852,25 @@ function ChatViewContent(props: ChatViewProps) { } else { turnStartSucceeded = true; acknowledgeActiveThreadWoke(); + // A /goal on a local draft has no thread to attach to yet, so the + // Objective is set once the first turn has created one. + if (pendingGoalObjective !== null) { + const goalResult = await setGoal({ + environmentId, + input: { + threadId: threadIdForSend, + objective: pendingGoalObjective, + messageId: messageIdForSend, + }, + }); + if (goalResult._tag === "Failure" && !isAtomCommandInterrupted(goalResult)) { + const error = squashAtomCommandFailure(goalResult); + setThreadError( + threadIdForSend, + error instanceof Error ? error.message : "Failed to update the Objective.", + ); + } + } } } diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index b8633e3b3ed8..a6dcb52cdc96 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -28,6 +28,7 @@ import { import { useNavigate, useParams } from "@tanstack/react-router"; import * as Option from "effect/Option"; import { + CrosshairIcon, ArrowLeftIcon, CloudUploadIcon, CornerLeftUpIcon, @@ -77,7 +78,13 @@ import { serverEnvironment } from "../state/server"; import { useAtomCommand } from "../state/use-atom-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; -import { useProjects, useThreadShells } from "../state/entities"; +import { + formatGoalStatusMessage, + goalChipActionLabel, + goalChipActions, +} from "@t3tools/shared/composerTrigger"; +import { useThreadGoalActions } from "../hooks/useThreadGoalActions"; +import { readEnvironmentSupportsGoal, useProjects, useThreadShells } from "../state/entities"; import { useThreadSearch } from "../state/queries"; import { resolveThreadActionProjectRef, startNewThreadFromContext } from "../lib/chatThreadActions"; import { @@ -607,6 +614,7 @@ function OpenCommandPaletteDialog(props: { const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } = useHandleNewThread(); const startComputerThread = useStartComputerThread(); + const { runGoalAction, showGoalStatus } = useThreadGoalActions(); const projects = useProjects(); const projectOrder = useUiStateStore((store) => store.projectOrder); const threads = useThreadShells(); @@ -1756,6 +1764,48 @@ function OpenCommandPaletteDialog(props: { }); } + if (activeThread && readEnvironmentSupportsGoal(activeThread.environmentId)) { + const goal = activeThread.goal ?? null; + const goalForStatus = goal == null ? null : { status: goal.status, objective: goal.objective }; + actionItems.push({ + kind: "action", + value: "action:goal-status", + searchTerms: ["goal", "objective", "status", "/goal"], + title: "Show Objective status", + description: formatGoalStatusMessage(goalForStatus), + icon: , + run: async () => { + showGoalStatus(goalForStatus); + }, + }); + if (goal != null) { + for (const action of goalChipActions(goal.status)) { + actionItems.push({ + kind: "action", + value: `action:goal-${action}`, + searchTerms: [ + "goal", + "objective", + action, + goalChipActionLabel(action), + "/goal", + `/goal ${action}`, + ], + title: `${goalChipActionLabel(action)} Objective`, + description: goal.objective, + icon: , + run: async () => { + await runGoalAction({ + environmentId: activeThread.environmentId, + threadId: activeThread.id, + action, + }); + }, + }); + } + } + } + actionItems.push({ kind: "action", value: "action:theme-editor", diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 3794796caa58..e31d95ee4cb3 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -172,6 +172,7 @@ import { snoozeWakeLabel, type SnoozePreset, } from "./Sidebar.snooze"; +import { GoalActiveMarker } from "./chat/GoalChip"; import { ProjectFavicon } from "./ProjectFavicon"; import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon"; import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils"; @@ -1302,6 +1303,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { /> {title} + {terminalStatusIcon} {isRegeneratingTitle ? ( @@ -1580,8 +1582,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ) : null} -
+
{title} + {isRegeneratingTitle ? ( Regenerating title @@ -1727,6 +1730,7 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {
{thread.title} + {threadTimeLabel(thread)} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 9289fe99611c..efcbb9235a5b 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -27,6 +27,7 @@ import { scopedThreadKey } from "@t3tools/client-runtime/environment"; import { serializeComposerFileLink, serializeComposerThreadLink, + BUILT_IN_GOAL_SLASH_COMMANDS, } from "@t3tools/shared/composerTrigger"; import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; import { appendVoiceTranscript as appendVoiceTranscriptText } from "@t3tools/shared/voiceTranscription"; @@ -50,6 +51,7 @@ import { expandCollapsedComposerCursor, replaceTextRange, shouldSubmitComposerOnEnter, + buildBuiltInSlashCommandItems, } from "../../composer-logic"; import { DISCONNECTED_COMPOSER_PLACEHOLDER } from "../../composerPlaceholder"; import { deriveComposerSendState, readFileAsDataUrl } from "../ChatView.logic"; @@ -1170,45 +1172,23 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) })); } if (composerTrigger.kind === "slash-command") { - const builtInSlashCommandItems = [ - { - id: "slash:model", - type: "slash-command", - command: "model", - label: "/model", - description: "Switch response model for this thread", - }, - ...(planModeUiEnabled - ? ([ - { - id: "slash:plan", - type: "slash-command", - command: "plan", - label: "/plan", - description: "Switch this thread into plan mode", - }, - { - id: "slash:default", - type: "slash-command", - command: "default", - label: "/default", - description: "Switch this thread back to normal build mode", - }, - ] as const) - : []), - ] satisfies ReadonlyArray>; + const builtInSlashCommandItems = buildBuiltInSlashCommandItems({ + planModeUiEnabled, + }) satisfies ReadonlyArray>; const providerSlashCommandItems = ( workspaceCapabilities.slashCommands ?? selectedProviderStatus?.slashCommands ?? [] - ).map((command) => ({ - id: `provider-slash-command:${selectedProvider}:${command.name}`, - type: "provider-slash-command" as const, - provider: selectedProvider, - command, - label: `/${command.name}`, - description: command.description ?? command.input?.hint ?? "Run provider command", - })); + ) + .filter((command) => command.name.toLowerCase() !== "goal") + .map((command) => ({ + id: `provider-slash-command:${selectedProvider}:${command.name}`, + type: "provider-slash-command" as const, + provider: selectedProvider, + command, + label: `/${command.name}`, + description: command.description ?? command.input?.hint ?? "Run provider command", + })); const query = composerTrigger.query.trim().toLowerCase(); const slashCommandItems = [...builtInSlashCommandItems, ...providerSlashCommandItems]; if (!query) { diff --git a/apps/web/src/composer-logic.test.ts b/apps/web/src/composer-logic.test.ts index e3dd1b056b44..c50e1f5ee895 100644 --- a/apps/web/src/composer-logic.test.ts +++ b/apps/web/src/composer-logic.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import { + buildBuiltInSlashCommandItems, clampCollapsedComposerCursor, collapseExpandedComposerCursor, detectComposerTrigger, @@ -388,3 +389,25 @@ describe("parseStandaloneComposerSlashCommand", () => { expect(parseStandaloneComposerSlashCommand("/goal Reduce p95")).toBeNull(); }); }); + +describe("buildBuiltInSlashCommandItems", () => { + it("offers the goal commands the composer owns itself", () => { + const items = buildBuiltInSlashCommandItems({ planModeUiEnabled: false }); + + expect(items.map((item) => item.id)).toEqual([ + "slash:model", + "slash:goal", + "slash:goal-pause", + "slash:goal-resume", + "slash:goal-clear", + ]); + }); + + it("adds the plan-mode commands only when plan mode is on", () => { + const withoutPlan = buildBuiltInSlashCommandItems({ planModeUiEnabled: false }); + const withPlan = buildBuiltInSlashCommandItems({ planModeUiEnabled: true }); + + expect(withoutPlan.some((item) => item.id === "slash:plan")).toBe(false); + expect(withPlan.map((item) => item.id).slice(-2)).toEqual(["slash:plan", "slash:default"]); + }); +}); diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts index 877821c11283..93d6719f4b34 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -1,3 +1,5 @@ +import { BUILT_IN_GOAL_SLASH_COMMANDS } from "@t3tools/shared/composerTrigger"; + import { splitPromptIntoComposerSegments } from "./composer-editor-mentions"; import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext"; @@ -301,3 +303,53 @@ export function replaceTextRange( const nextText = `${text.slice(0, safeStart)}${replacement}${text.slice(safeEnd)}`; return { text: nextText, cursor: safeStart + replacement.length }; } + +/** + * The slash commands the composer offers itself, before the provider's own. + * Extracted from the menu so the built-ins stay covered: the goal entries have + * been dropped by an integration merge before, and nothing unit-level noticed. + */ +export function buildBuiltInSlashCommandItems(options: { + readonly planModeUiEnabled: boolean; +}): ReadonlyArray<{ + readonly id: string; + readonly type: "slash-command"; + readonly command: ComposerSlashCommand; + readonly label: string; + readonly description: string; +}> { + return [ + { + id: "slash:model", + type: "slash-command", + command: "model", + label: "/model", + description: "Switch response model for this thread", + }, + ...BUILT_IN_GOAL_SLASH_COMMANDS.map((item) => ({ + id: `slash:${item.command.replaceAll(" ", "-")}`, + type: "slash-command" as const, + command: item.command, + label: item.label, + description: item.description, + })), + ...(options.planModeUiEnabled + ? ([ + { + id: "slash:plan", + type: "slash-command", + command: "plan", + label: "/plan", + description: "Switch this thread into plan mode", + }, + { + id: "slash:default", + type: "slash-command", + command: "default", + label: "/default", + description: "Switch this thread back to normal build mode", + }, + ] as const) + : []), + ]; +}