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
3 changes: 3 additions & 0 deletions apps/server/src/agents/tmux/command-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,9 @@ export function buildLaunchGuidance(
? "Report status with dispatch_event as you work and before your final response — blocked means genuinely stuck, not an error you're about to fix. Your reported status is verified against session activity and auto-corrected."
: "Report status with dispatch_event. Types: working (making progress — includes debugging, fixing test failures, investigating errors), blocked (completely stuck with no further approach to try — NOT for errors or test failures you plan to fix next), waiting_user (need a decision or approval), done (task complete), idle (no-op, just answered a question). Emit working at turn start and when shifting phases. Emit a terminal event before your final response. Your reported status is verified against session activity and auto-corrected when it doesn't match."
);
rules.push(
"Once you accept a task, do not end a turn after only announcing a plan or status. Continue into substantive work in the same turn, or explicitly report waiting_user or blocked when you genuinely cannot proceed."
);
if (chatSurface) {
rules.push(CHAT_SURFACE_GUIDANCE_RULE);
}
Expand Down
12 changes: 12 additions & 0 deletions apps/server/test/tmux-command-builder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1111,6 +1111,18 @@ describe("buildLaunchGuidance — trimmed variant", () => {
expect(text).toContain("auto-corrected");
});

it("requires accepted tasks to continue past plan-only turns", () => {
for (const agentType of ["claude", "codex"] as const) {
for (const trimmedGuidance of [false, true]) {
const text = guidance({ agentType, trimmedGuidance });
expect(text).toContain(
"do not end a turn after only announcing a plan or status"
);
expect(text).toContain("Continue into substantive work");
}
}
});

it("folds the two pin rules into one", () => {
const full = guidance({ agentType: "claude" });
const text = guidance({ agentType: "claude", trimmedGuidance: true });
Expand Down
28 changes: 25 additions & 3 deletions apps/web/src/components/app/chat/chat-entries.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@
* record (not a Map) so React Query's structural sharing keeps its identity
* across agent updates that change nothing here.
*/
export function peerDirectory(

Check warning on line 111 in apps/web/src/components/app/chat/chat-entries.tsx

View workflow job for this annotation

GitHub Actions / ci

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
agentId: string,
agents: readonly Pick<Agent, "id" | "name" | "type" | "parentAgentId">[]
): PeerDirectory {
Expand Down Expand Up @@ -192,7 +192,7 @@
* (dispatch_launch_agent) is that agent's, named from the agents list when
* it is still there and "Agent" otherwise; every other user post is "You".
*/
export function chatMessageAuthor(

Check warning on line 195 in apps/web/src/components/app/chat/chat-entries.tsx

View workflow job for this annotation

GitHub Actions / ci

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
message: ChatMessage,
ctx: FeedContext
): PostAuthor {
Expand Down Expand Up @@ -255,7 +255,7 @@
* stays plain. The tint runs the whole author group, so a run of posts
* reads as one block.
*/
export const POST_TINT: Record<PostAuthor["kind"], string> = {

Check warning on line 258 in apps/web/src/components/app/chat/chat-entries.tsx

View workflow job for this annotation

GitHub Actions / ci

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
// Only the user's own posts get a fill. At 6% `primary` sat too close to the
// page background in several themes to notice; 10% reads everywhere.
//
Expand Down Expand Up @@ -767,6 +767,12 @@
if (!question) return null;
const answer = message.answer;
const open = answer === null;
const answeredOption = answer
? (question.options.find(
(option) => (option.value ?? option.label) === answer.value
) ?? null)
: null;
const answerDisplay = answeredOption?.label ?? answer?.value ?? "";
const optionsDisabled = answer !== null || answering || answersDisabled;
return (
<div
Expand All @@ -790,7 +796,16 @@
<div className="mb-2 flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground">
<Check className="h-3 w-3" />
Answered
<span className="truncate">· {answer.label ?? answer.value}</span>
<span className="flex min-w-0 items-center gap-1">
<span aria-hidden="true">·</span>
{answeredOption ? (
<Markdown variant="inline" className="truncate">
{answerDisplay}
</Markdown>
) : (
<span className="truncate">{answerDisplay}</span>
)}
</span>
</div>
)}
<div className="flex flex-wrap gap-1.5">
Expand All @@ -817,7 +832,7 @@
onClick={() => onAnswer(option)}
>
{chosen ? <Check className="h-3 w-3" /> : null}
{option.label}
<Markdown variant="inline">{option.label}</Markdown>
</Button>
);
})}
Expand Down Expand Up @@ -891,6 +906,7 @@
ctx,
answering,
answersDisabled = false,
answeredOptionLabel = null,
onAnswer,
}: {
message: ChatMessage;
Expand All @@ -902,6 +918,8 @@
answering: boolean;
/** Answers go through the same injection as the composer; lock them together. */
answersDisabled?: boolean;
/** Canonical option label when this user row answers a declared option. */
answeredOptionLabel?: string | null;
onAnswer: (messageId: string, option: ChatQuestionOption) => void;
}): JSX.Element {
const copyAction = message.text ? (
Expand Down Expand Up @@ -934,7 +952,11 @@
) : null}
{message.text ? (
<div className="whitespace-pre-wrap break-words [overflow-wrap:anywhere]">
{message.text}
{answeredOptionLabel ? (
<Markdown variant="inline">{answeredOptionLabel}</Markdown>
) : (
message.text
)}
</div>
) : null}
<AttachmentList attachments={message.attachments} ctx={ctx} />
Expand Down
70 changes: 63 additions & 7 deletions apps/web/src/components/app/chat/chat-feed.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -887,15 +887,18 @@ describe("ChatFeed", () => {
expect(update!.textContent).toContain("Still going");
});

it("renders an unanswered question with clickable options", () => {
it("renders an unanswered question with clickable Markdown options", () => {
const { onAnswer } = renderFeed([
chat(
message({
id: "q1",
kind: "question",
text: "Which one?",
question: {
options: [{ label: "Alpha", value: "a" }, { label: "Beta" }],
options: [
{ label: "**Alpha** uses `a`", value: "a" },
{ label: "Beta" },
],
allowFreeform: true,
},
})
Expand All @@ -906,6 +909,9 @@ describe("ChatFeed", () => {
const options = screen.getAllByTestId("chat-question-option");
expect(options).toHaveLength(2);
expect(options.every((o) => !(o as HTMLButtonElement).disabled)).toBe(true);
expect(options[0]!.textContent).toBe("Alpha uses a");
expect(options[0]!.querySelector("strong")?.textContent).toBe("Alpha");
expect(options[0]!.querySelector("code")?.textContent).toBe("a");

fireEvent.click(options[1]!);
expect(onAnswer).toHaveBeenCalledWith("q1", { label: "Beta" });
Expand All @@ -924,23 +930,45 @@ describe("ChatFeed", () => {
kind: "question",
text: "Which one?",
question: {
options: [{ label: "Alpha", value: "a" }, { label: "Beta" }],
options: [
{ label: "**Alpha** uses `a`", value: "a" },
{ label: "Beta" },
],
allowFreeform: true,
},
answer: {
value: "a",
label: "Alpha",
label: "**Alpha** uses `a`",
replyMessageId: "u9",
answeredAt: "2026-09-02T10:01:00.000Z",
},
})
),
chat(
message({
id: "u9",
authorKind: "user",
text: "**Alpha** uses `a`",
replyTo: "q1",
delivered: true,
createdAt: "2026-09-02T10:01:00.000Z",
updatedAt: "2026-09-02T10:01:00.000Z",
})
),
]);
expect(screen.queryByTestId("chat-needs-reply")).toBeNull();
expect(screen.queryByText("Or type a reply below.")).toBeNull();
expect(screen.getByTestId("chat-question-options").textContent).toContain(
"Answered"
);
const card = screen.getByTestId("chat-question-options");
expect(card.textContent).toContain("Answered");
expect(card.textContent).not.toContain("**Alpha**");
expect(card.querySelector("strong")?.textContent).toBe("Alpha");
expect(card.querySelector("code")?.textContent).toBe("a");
const userReply = screen
.getAllByTestId("chat-message")
.find((row) => row.getAttribute("data-message-id") === "u9")!;
expect(userReply.textContent).not.toContain("**Alpha**");
expect(userReply.querySelector("strong")?.textContent).toBe("Alpha");
expect(userReply.querySelector("code")?.textContent).toBe("a");
const options = screen.getAllByTestId("chat-question-option");
expect(options.every((o) => (o as HTMLButtonElement).disabled)).toBe(true);
expect(options[0]!.getAttribute("aria-pressed")).toBe("true");
Expand All @@ -949,6 +977,34 @@ describe("ChatFeed", () => {
expect(onAnswer).not.toHaveBeenCalled();
});

it("keeps a freeform answer literal in the answered summary", () => {
renderFeed([
chat(
message({
id: "q-freeform",
kind: "question",
text: "Other?",
question: {
options: [{ label: "Suggested" }],
allowFreeform: true,
},
answer: {
value: "__init__.py uses `literal` *marks*",
label: "__init__.py uses `literal` *marks*",
replyMessageId: "u10",
answeredAt: "2026-09-02T10:01:00.000Z",
},
})
),
]);

const card = screen.getByTestId("chat-question-options");
expect(card.textContent).toContain("__init__.py uses `literal` *marks*");
expect(card.querySelector("strong")).toBeNull();
expect(card.querySelector("code")).toBeNull();
expect(card.querySelector("em")).toBeNull();
});

it("locks options and hides the freeform hint while answers are unavailable", () => {
renderFeed(
[
Expand Down
21 changes: 21 additions & 0 deletions apps/web/src/components/app/chat/chat-feed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,15 @@ export function ChatFeed({
answersDisabled = false,
onAnswer,
}: ChatFeedProps): JSX.Element {
const messageDirectory = useMemo(
() =>
new Map(
entries
.filter((entry) => entry.type === "chat")
.map((entry) => [entry.message.id, entry.message] as const)
),
[entries]
);
const rows = useMemo(() => layoutFeed(entries, ctx), [entries, ctx]);
const entering = useEnteringEntries(entries);

Expand Down Expand Up @@ -388,6 +397,17 @@ export function ChatFeed({
}
if (row.kind === "status") return null;
const entry = row.entry;
const answeredOptionLabel = (() => {
if (entry.type !== "chat" || !entry.message.replyTo) return null;
const question = messageDirectory.get(entry.message.replyTo);
if (question?.answer?.replyMessageId !== entry.message.id)
return null;
const option = question.question?.options.find(
(candidate) =>
(candidate.value ?? candidate.label) === question.answer?.value
);
return option?.label ?? null;
})();
const view = (() => {
switch (entry.type) {
case "chat":
Expand All @@ -400,6 +420,7 @@ export function ChatFeed({
ctx={ctx}
answering={answeringMessageId === entry.message.id}
answersDisabled={answersDisabled}
answeredOptionLabel={answeredOptionLabel}
onAnswer={onAnswer}
/>
);
Expand Down
29 changes: 28 additions & 1 deletion apps/web/src/components/ui/markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ function getCodeBlock(
type MarkdownProps = {
children: string;
className?: string;
variant?: "default" | "pin" | "caption";
variant?: "default" | "pin" | "caption" | "inline";
// Colors h1/h2 for skimming a long document (see MarkdownDefault). Off by
// default: most `default`-variant consumers are compact cards that pass
// their own dimmed base color (e.g. text-muted-foreground, text-foreground/85)
Expand All @@ -55,13 +55,40 @@ export const Markdown = memo(function Markdown({
return <MarkdownCaption className={className}>{children}</MarkdownCaption>;
}

if (variant === "inline") {
return <MarkdownInline className={className}>{children}</MarkdownInline>;
}

return (
<MarkdownDefault className={className} headingAccents={headingAccents}>
{children}
</MarkdownDefault>
);
});

function MarkdownInline({
children,
className,
}: Pick<MarkdownProps, "children" | "className">): JSX.Element {
return (
<span
className={cn(
"[&_strong]:font-semibold [&_em]:italic [&_del]:line-through",
"[&_code]:rounded [&_code]:bg-current/10 [&_code]:px-1 [&_code]:font-mono [&_code]:text-[0.9em]",
className
)}
>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
allowedElements={["strong", "em", "code", "del"]}
unwrapDisallowed
>
{children}
</ReactMarkdown>
</span>
);
}

/**
* Single-line muted markdown for subtitles (e.g. a shortcut pin's caption).
* Inline marks only — block elements are unwrapped so the caption can never
Expand Down
Loading