Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion apps/web/e2e/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
137 changes: 135 additions & 2 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<unknown, unknown>): 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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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}`;
Expand Down Expand Up @@ -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.",
);
}
}
}
}

Expand Down
52 changes: 51 additions & 1 deletion apps/web/src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
import { useNavigate, useParams } from "@tanstack/react-router";
import * as Option from "effect/Option";
import {
CrosshairIcon,
ArrowLeftIcon,
CloudUploadIcon,
CornerLeftUpIcon,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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: <CrosshairIcon className={ITEM_ICON_CLASS} />,
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: <CrosshairIcon className={ITEM_ICON_CLASS} />,
run: async () => {
await runGoalAction({
environmentId: activeThread.environmentId,
threadId: activeThread.id,
action,
});
},
});
}
}
}

actionItems.push({
kind: "action",
value: "action:theme-editor",
Expand Down
6 changes: 5 additions & 1 deletion apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -1302,6 +1303,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
/>
</span>
{title}
<GoalActiveMarker goal={thread.goal} />
{terminalStatusIcon}
{isRegeneratingTitle ? (
<span role="status" className="sr-only">
Expand Down Expand Up @@ -1580,8 +1582,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
) : null}
</span>
</div>
<div className="mt-1 flex min-w-0">
<div className="mt-1 flex min-w-0 items-center gap-1.5">
{title}
<GoalActiveMarker goal={thread.goal} />
{isRegeneratingTitle ? (
<span role="status" className="sr-only">
Regenerating title
Expand Down Expand Up @@ -1727,6 +1730,7 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {
<div className="flex min-w-0 flex-1 flex-col justify-center">
<div className="flex items-center justify-between gap-2">
<span className="min-w-0 flex-1 truncate">{thread.title}</span>
<GoalActiveMarker goal={thread.goal} />
<span className="shrink-0 text-xs text-muted-foreground/55 tabular-nums">
{threadTimeLabel(thread)}
</span>
Expand Down
50 changes: 15 additions & 35 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -50,6 +51,7 @@ import {
expandCollapsedComposerCursor,
replaceTextRange,
shouldSubmitComposerOnEnter,
buildBuiltInSlashCommandItems,
} from "../../composer-logic";
import { DISCONNECTED_COMPOSER_PLACEHOLDER } from "../../composerPlaceholder";
import { deriveComposerSendState, readFileAsDataUrl } from "../ChatView.logic";
Expand Down Expand Up @@ -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<Extract<ComposerCommandItem, { type: "slash-command" }>>;
const builtInSlashCommandItems = buildBuiltInSlashCommandItems({
planModeUiEnabled,
}) satisfies ReadonlyArray<Extract<ComposerCommandItem, { type: "slash-command" }>>;
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) {
Expand Down
Loading
Loading