Skip to content
Closed
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
7 changes: 3 additions & 4 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2357,7 +2357,6 @@ export default function ChatView(props: ChatViewProps) {
attentionNow,
workNow,
childInputNow,
goalNow,
handleSwitchCheckoutToThread,
handleSwitchCheckoutToThreadStable,
isRestoringThreadBranch,
Expand Down Expand Up @@ -2998,8 +2997,7 @@ export default function ChatView(props: ChatViewProps) {
};
const onExpandTimelineImageStable = useStableCallback(onExpandTimelineImage);
const shownThreadSyncPhase = activeEnvironmentUnavailable ? null : delayedThreadSyncPhase;
const hasThreadNow =
attentionNow !== null || workNow !== null || goalNow !== null || shownThreadSyncPhase !== null;
const hasThreadNow = attentionNow !== null || workNow !== null || shownThreadSyncPhase !== null;
const queuedRunCount = serverProjection ? getUserQueuedThreadRuns(serverProjection).length : 0;
// The queue control is mounted for every server thread but joins the footer only while it shows
// something, by its own rule: an optimistic message stops counting once the server has it.
Expand Down Expand Up @@ -3041,7 +3039,6 @@ export default function ChatView(props: ChatViewProps) {
attention={attentionNow}
work={workNow}
syncPhase={shownThreadSyncPhase}
goal={goalNow}
/>
{threadStatusLine}
{pendingGuestMessageCount > 0 && isServerThread && activeThread ? (
Expand Down Expand Up @@ -3498,6 +3495,7 @@ export default function ChatView(props: ChatViewProps) {
isLocalDraftThread && activeProject === null
}
phase={phase}
threadWorking={isWorking || phase === "connecting"}
isCompacting={isCompacting}
canInterrupt={canInterruptRunningThread}
isSendBusy={
Expand Down Expand Up @@ -3590,6 +3588,7 @@ export default function ChatView(props: ChatViewProps) {
onPageScrollRelease={onComposerPageScrollReleaseStable}
onCompactContext={onComposerCompactContextStable}
onSend={onComposerSendStable}
onSendStandaloneCommand={sendStandaloneCommand}
onResume={onComposerResumeStable}
onInterrupt={onInterruptStable}
onImplementPlanInNewThread={onImplementPlanInNewThreadStable}
Expand Down
28 changes: 25 additions & 3 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,8 @@ import {
} from "./composerContextUndo";
import type { ThreadSyncPhase } from "../../threadSync";
import { ComposerBanner } from "./ComposerBanner";
import { ComposerGoalBar } from "./ComposerGoalBar";
import { presentComposerGoalBar } from "./presentComposerGoalBar";
import { ComposerSurface } from "./ComposerSurface";
import { ComposerBannerStack, type ComposerBannerStackItem } from "./ComposerBannerStack";
import {
Expand Down Expand Up @@ -370,6 +372,8 @@ export interface ChatComposerProps {
projectSelectionRequired: boolean;

phase: SessionPhase;
/** The thread is working, including runless work the session phase does not show. */
threadWorking: boolean;
canInterrupt: boolean;
isSendBusy: boolean;
canResume: boolean;
Expand Down Expand Up @@ -463,6 +467,8 @@ export interface ChatComposerProps {
dispatchMode?: ComposerDispatchMode,
submissionIntent?: ComposerSubmissionIntent,
) => void;
/** Sends a composer command without replacing the draft, such as `/goal resume`. */
onSendStandaloneCommand: (text: string, failureMessage: string) => Promise<void>;
onResume: () => void;
onInterrupt: () => void;
onImplementPlanInNewThread: () => void;
Expand Down Expand Up @@ -914,6 +920,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
selectedProviderEntry?.snapshot,
selectedModel,
);
const providerAvailable =
multipleModelSelections !== null || (!noProviderAvailable && providerSendBlockReason === null);
const sendDisabledReason =
externalSendDisabledReason ??
(multipleModelSelections?.length === 0 ? "Select at least one model." : null) ??
Expand Down Expand Up @@ -2959,9 +2967,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
: selection,
)
: null,
providerAvailable:
multipleModelSelections !== null ||
(!noProviderAvailable && providerSendBlockReason === null),
providerAvailable,
selectedProvider,
selectedModel,
selectedProviderModels,
Expand Down Expand Up @@ -3026,6 +3032,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
setMultipleModelSelections,
routeKind,
noProviderAvailable,
providerAvailable,
providerSendBlockReason,
selectedPromptEffort,
selectedProvider,
Expand Down Expand Up @@ -3079,6 +3086,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
onRemoveFile={removeComposerFileFromDraft}
/>
) : null;
const composerGoal = presentComposerGoalBar({
goal: props.activeThreadShell?.goal ?? null,
working: props.threadWorking,
// A pending answer advances that question. It does not send `/goal`.
prompt: activePendingProgress ? "" : prompt,
canPauseResume: selectedProvider === "codex",
hasAttachments: composerImages.length + composerFiles.length > 0,
});
const composerAttachButton = showComposerAttachAction ? (
<ComposerAttachButton
addComposerAttachments={addComposerAttachments}
Expand Down Expand Up @@ -3196,6 +3211,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
composerProviderState.composerSurfaceClassName,
)}
>
{composerGoal === null ? null : (
<ComposerGoalBar
model={composerGoal}
canOperate={canOperateThread && providerAvailable}
onSendCommand={props.onSendStandaloneCommand}
/>
)}
{showCollapsedMobilePromptRow ? (
<div className="flex items-center justify-between gap-2 px-3 py-2">
<button
Expand Down
58 changes: 58 additions & 0 deletions apps/web/src/components/chat/ComposerGoalBar.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { expect, it, vi } from "vite-plus/test";

import "~/index.css";

import { render } from "../../../test/render";
import { ComposerGoalBar } from "./ComposerGoalBar";
import { presentComposerGoalBar } from "./presentComposerGoalBar";

function modelFor(status: "active" | "paused", prompt: string) {
const model = presentComposerGoalBar({
goal: { objective: "All tests in packages/api pass", status },
working: status === "active",
prompt,
canPauseResume: true,
});
if (model === null) throw new Error("expected a goal bar");
return model;
}

it("shows the goal a /goal resume draft continues, with play", async () => {
await using screen = await render(
<ComposerGoalBar
model={modelFor("paused", "/goal resume")}
canOperate
onSendCommand={() => Promise.resolve()}
/>,
);

expect(screen.getByRole("status").element().textContent).toContain("Resuming goal");
expect(screen.getByRole("status").element().textContent).toContain(
"All tests in packages/api pass",
);
expect(screen.getByRole("button", { name: "Resume goal" }).element()).toBeTruthy();
});

it("pauses an active goal from the bar, including one set with an objective", async () => {
const onSendCommand = vi.fn(() => Promise.resolve());
await using screen = await render(
<ComposerGoalBar
model={modelFor("active", "/goal ship the fix")}
canOperate
onSendCommand={onSendCommand}
/>,
);

await screen.getByRole("button", { name: "Pause goal" }).click();
expect(onSendCommand).toHaveBeenCalledWith("/goal pause", "Failed to pause goal.");
});

it("resumes a paused goal from the play control", async () => {
const onSendCommand = vi.fn(() => Promise.resolve());
await using screen = await render(
<ComposerGoalBar model={modelFor("paused", "")} canOperate onSendCommand={onSendCommand} />,
);

await screen.getByRole("button", { name: "Resume goal" }).click();
expect(onSendCommand).toHaveBeenCalledWith("/goal resume", "Failed to resume goal.");
});
94 changes: 94 additions & 0 deletions apps/web/src/components/chat/ComposerGoalBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { PauseIcon, PlayIcon, TargetIcon } from "lucide-react";

import { cn } from "~/lib/utils";
import { Button, InlineButton } from "../ui/button";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
import type { ComposerGoalBarModel } from "./presentComposerGoalBar";

/** The thread's goal, sitting on the top edge of the composer above the message field. */
export function ComposerGoalBar({
model,
canOperate,
onSendCommand,
}: {
readonly model: ComposerGoalBarModel;
readonly canOperate: boolean;
readonly onSendCommand: (text: string, failureMessage: string) => Promise<void>;
}) {
const emphasized = model.emphasizing !== null;
const showClear = model.emphasizing !== "clear";
const playbackLabel = model.playback === "pause" ? "Pause goal" : "Resume goal";
const label =
model.usage === null
? `${model.title}. ${model.objective}`
: `${model.title}. ${model.objective}. ${model.usage}`;
return (
<div
role="status"
data-chat-composer-goal=""
data-goal-emphasis={model.emphasizing ?? "resting"}
aria-label={label}
className={cn(
"flex min-w-0 items-center gap-2 border-b px-3 py-1.5 text-xs",
emphasized ? "border-primary/25 bg-primary/10" : "border-border/70",
)}
>
<TargetIcon
aria-hidden
className={cn(
"size-3.5 shrink-0",
emphasized ? "text-foreground" : "text-muted-foreground",
)}
/>
<span className="shrink-0 font-medium text-foreground">{model.title}</span>
<Tooltip>
<TooltipTrigger
render={
<span
className={cn(
"min-w-0 flex-1 truncate text-left",
emphasized ? "text-foreground" : "text-muted-foreground",
)}
>
{model.objective}
</span>
}
/>
<TooltipPopup side="top">{model.objective}</TooltipPopup>
</Tooltip>
{model.usage === null ? null : (
<span className="min-w-0 truncate tabular-nums text-muted-foreground">{model.usage}</span>
)}
{model.playback !== null || showClear ? (
<span className="flex shrink-0 items-center gap-1">
{model.playback === null ? null : (
<Button
type="button"
size="icon-xs"
variant="ghost"
disabled={!canOperate}
aria-label={playbackLabel}
onClick={() =>
void onSendCommand(
model.playback === "pause" ? "/goal pause" : "/goal resume",
model.playback === "pause" ? "Failed to pause goal." : "Failed to resume goal.",
)
}
>
{model.playback === "pause" ? <PauseIcon /> : <PlayIcon />}
</Button>
)}
{showClear ? (
<InlineButton
tone="muted"
disabled={!canOperate}
onClick={() => void onSendCommand("/goal clear", "Failed to clear goal.")}
>
Clear
</InlineButton>
) : null}
</span>
) : null}
</div>
);
}
49 changes: 1 addition & 48 deletions apps/web/src/components/chat/ThreadActivityBanners.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,7 @@
import type {
OrchestrationV2PendingBackgroundTask,
OrchestrationV2ProviderGoal,
ThreadId,
} from "@t3tools/contracts";
import type { OrchestrationV2PendingBackgroundTask, ThreadId } from "@t3tools/contracts";
import type { EnvironmentThreadShell, ThreadStructure } from "@t3tools/client-runtime/state/shell";
import {
presentPendingBackgroundWork,
presentProviderGoal,
type RunStopPresentation,
} from "@t3tools/client-runtime/state/thread-execution";
import { childThreadInputsTitle } from "@t3tools/client-runtime/state/threads";
Expand All @@ -18,7 +13,6 @@ import {
GitBranchIcon,
MessageCircleIcon,
ShieldQuestionIcon,
TargetIcon,
TerminalIcon,
TriangleAlertIcon,
} from "lucide-react";
Expand Down Expand Up @@ -67,47 +61,6 @@ export function childInputNowItem(input: {
};
}

/** The thread's goal, for the end of the timeline. */
export function goalNowItem(input: {
readonly threadId: ThreadId;
readonly goal: OrchestrationV2ProviderGoal;
readonly isWorking: boolean;
readonly onSendCommand: (text: string, failureMessage: string) => Promise<void>;
}): ThreadNowItem {
const { isWorking, onSendCommand } = input;
const presentation = presentProviderGoal(input.goal, isWorking);
return {
id: `goal:${input.threadId}`,
icon: <TargetIcon />,
title:
presentation.usage === null
? presentation.title
: `${presentation.title} · ${presentation.usage}`,
description: presentation.objective,
// Clear stops goal work; Resume would stop unrelated work, so it waits.
actions: (
<>
{presentation.canResume && !isWorking ? (
<>
<InlineButton
onClick={() => void onSendCommand("/goal resume", "Failed to resume goal.")}
>
Resume
</InlineButton>
<span aria-hidden>·</span>
</>
) : null}
<InlineButton
tone="muted"
onClick={() => void onSendCommand("/goal clear", "Failed to clear goal.")}
>
Clear
</InlineButton>
</>
),
};
}

/** A Stop the provider has not finished; Force stop once it stops answering. */
export function runStopNowItem(input: {
readonly threadId: ThreadId;
Expand Down
5 changes: 1 addition & 4 deletions apps/web/src/components/chat/ThreadNow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,26 +78,23 @@ function ThreadSyncLine({ phase }: { readonly phase: ThreadSyncPhase }) {

/**
* What the thread is doing now, at the end of the timeline: what needs you, background work,
* the sync status while messages load, then the goal.
* then the sync status while messages load. The goal sits on the composer, above the field.
*/
export function ThreadNow({
attention,
work,
syncPhase,
goal,
}: {
/** Something that needs you elsewhere, such as a subagent's question. */
readonly attention: ThreadNowItem | null;
readonly work: ThreadNowItem | null;
readonly syncPhase: ThreadSyncPhase | null;
readonly goal: ThreadNowItem | null;
}): ReactNode {
return (
<>
{attention ? <ThreadNowLine item={attention} announce /> : null}
{work ? <ThreadNowLine item={work} /> : null}
{syncPhase ? <ThreadSyncLine phase={syncPhase} /> : null}
{goal ? <ThreadNowLine item={goal} /> : null}
</>
);
}
Loading
Loading