Skip to content
37 changes: 23 additions & 14 deletions src/browser/components/ChatPane/ChatPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1239,11 +1239,19 @@ const ChatPaneContent: React.FC<ChatPaneContentProps> = (props) => {
? (workBundleOverride ?? workBundle.defaultExpanded)
: false;

if (workBundle?.position === "member") {
const keepCollapsedWorkBundleMemberVisible =
msg.type === "user" ||
(msg.type === "assistant" &&
(msg.isSideAnswer === true || workBundle?.position === "final"));
if (
(workBundle?.position === "member" || workBundle?.position === "final") &&
(isWorkBundleExpanded || !keepCollapsedWorkBundleMemberVisible)
) {
return null;
}

const renderWorkBundle = workBundle?.position === "head";
const renderMessageBeforeWorkBundle = renderWorkBundle && msg.type === "user";
const renderMessageAfterWorkBundle = !renderWorkBundle;
const operationalBundle = workBundle
? undefined
Expand All @@ -1269,6 +1277,10 @@ const ChatPaneContent: React.FC<ChatPaneContentProps> = (props) => {

return (
<React.Fragment key={`${workspaceId}:${msg.id}`}>
{renderMessageBeforeWorkBundle &&
renderMessageAtIndex(msg, index, {
key: `${workspaceId}:${msg.id}:message`,
})}
{renderWorkBundle && workBundle && (
<WorkBundleMessage
item={workBundle}
Expand Down Expand Up @@ -1309,23 +1321,20 @@ const ChatPaneContent: React.FC<ChatPaneContentProps> = (props) => {
key={`${workspaceId}:${workBundle.key}:${entry.message.id}`}
>
{renderNestedBundle && nestedOperationalBundle && (
<div className="ml-4">
<OperationalBundleMessage
item={nestedOperationalBundle}
expanded={isNestedExpanded}
onToggle={() =>
setOperationalBundleExpanded(
nestedOperationalBundle.key,
!isNestedExpanded
)
}
/>
</div>
<OperationalBundleMessage
item={nestedOperationalBundle}
expanded={isNestedExpanded}
onToggle={() =>
setOperationalBundleExpanded(
nestedOperationalBundle.key,
!isNestedExpanded
)
}
/>
)}
{renderNestedMessage &&
renderMessageAtIndex(entry.message, entry.originalIndex, {
key: `${workspaceId}:${workBundle.key}:${entry.message.id}:message`,
className: nestedOperationalBundle ? "ml-8" : "ml-4",
})}
</React.Fragment>
);
Expand Down
89 changes: 57 additions & 32 deletions src/browser/features/Messages/TranscriptDensity.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,45 +36,69 @@ function setupTranscriptDensityStory(density: TranscriptDensity) {
createAssistantMessage("density-assistant-1", "I'll gather context first.", {
historySequence: 2,
timestamp: STABLE_TIMESTAMP - 55_000,
partial: true,
reasoning:
"Need to inspect auth code, search related validation guidance, and then make a minimal patch.",
toolCalls: [
createFileReadTool("density-read-1", "src/auth.ts", "export function verify() {}"),
createWebSearchTool("density-search-1", "JWT validation best practices", 3),
createAgentSkillReadTool("density-skill-1", "react-effects", { scope: "global" }),
createBashTool("density-rg-1", 'rg "verify" src', "src/auth.ts:1:verify"),
{
type: "text",
text: "I found the relevant code and will patch it.",
timestamp: STABLE_TIMESTAMP - 35_000,
},
createFileEditTool(
"density-edit-1",
"src/auth.ts",
[
"--- src/auth.ts",
"+++ src/auth.ts",
"@@ -1,3 +1,4 @@",
"+import { timingSafeEqual } from 'crypto';",
" export function verify() {}",
].join("\n")
createGenericTool(
"density-question-1",
"ask_user_question",
{ question: "Any additional validation needed?" },
{ answer: "Please validate with typecheck too" }
),
createBashTool(
"density-test-1",
"make test",
"42 tests passed",
0,
30,
500,
"Running tests"
),
{
type: "text",
text: "Implemented the auth audit fix and validated it.",
timestamp: STABLE_TIMESTAMP - 15_000,
},
],
}),
createUserMessage("density-user-2", "Please validate with typecheck too", {
historySequence: 3,
timestamp: STABLE_TIMESTAMP - 40_000,
}),
createAssistantMessage(
"density-assistant-2",
"I found the relevant code and will patch it.",
{
historySequence: 4,
timestamp: STABLE_TIMESTAMP - 35_000,
toolCalls: [
createFileEditTool(
"density-edit-1",
"src/auth.ts",
[
"--- src/auth.ts",
"+++ src/auth.ts",
"@@ -1,3 +1,4 @@",
"+import { timingSafeEqual } from 'crypto';",
" export function verify() {}",
].join("\n")
),
createBashTool(
"density-test-1",
"make test",
"42 tests passed",
0,
30,
500,
"Running tests"
),
createBashTool(
"density-fail-1",
"make typecheck",
"Type error in src/auth.ts",
1,
30,
500,
"Failing validation"
),
{
type: "text",
text: "Implemented the auth audit fix and validated it.",
timestamp: STABLE_TIMESTAMP - 15_000,
},
],
}
),
],
});
}
Expand Down Expand Up @@ -129,15 +153,16 @@ export const HyperActiveExpandedBundle: AppStory = {
setup={() => {
collapseLeftSidebar();
setDensity("hyper");
const activeStartedAt = Date.now() - 39_000;
return setupSimpleChatStory({
messages: [
createUserMessage("active-user-1", "Inspect the repository", {
historySequence: 1,
timestamp: STABLE_TIMESTAMP - 40_000,
timestamp: activeStartedAt - 5_000,
}),
createAssistantMessage("active-assistant-1", "I'll read the key files now.", {
historySequence: 2,
timestamp: STABLE_TIMESTAMP - 35_000,
timestamp: activeStartedAt,
toolCalls: [
createPendingTool("active-read-1", "file_read", { path: "src/App.tsx" }),
createPendingTool("active-search-1", "web_search", {
Expand Down
20 changes: 20 additions & 0 deletions src/browser/features/Messages/WorkBundleMessage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ const item: WorkBundleInfo = {
position: "head",
headIndex: 1,
entries: [],
startedAtMs: 0,
durationMs: 180_000,
state: "settled",
defaultExpanded: false,
};

Expand Down Expand Up @@ -46,6 +48,24 @@ describe("WorkBundleMessage", () => {
expect(view.getByRole("button", { expanded: true })).toBeDefined();
});

test("renders active working label with elapsed duration", () => {
const view = render(
<WorkBundleMessage
item={{
...item,
state: "active",
startedAtMs: Date.now() - 35_000,
durationMs: undefined,
defaultExpanded: true,
}}
expanded
onToggle={() => undefined}
/>
);

expect(view.getByText(/Working for \d+s\.\.\./)).toBeDefined();
});

test("renders fallback label without duration", () => {
const view = render(
<WorkBundleMessage
Expand Down
35 changes: 31 additions & 4 deletions src/browser/features/Messages/WorkBundleMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,43 @@ import { cn } from "@/common/lib/utils";
import { ExpandIcon } from "@/browser/features/Tools/Shared/ToolPrimitives";
import type { WorkBundleInfo } from "@/browser/utils/messages/transcriptRenderProjection";

function useActiveNowMs(isActive: boolean): number {
const [nowMs, setNowMs] = React.useState(() => Date.now());

React.useEffect(() => {
if (!isActive) {
return;
}

setNowMs(Date.now());
const intervalId = window.setInterval(() => setNowMs(Date.now()), 1_000);
return () => window.clearInterval(intervalId);
}, [isActive]);

return nowMs;
}

interface WorkBundleMessageProps {
item: WorkBundleInfo;
expanded: boolean;
onToggle: () => void;
}

export function WorkBundleMessage(props: WorkBundleMessageProps): React.ReactElement {
const duration = props.item.durationMs;
const label =
duration === undefined ? "Worked" : `Worked for ${formatDuration(duration, "precise")}`;
const isActive = props.item.state === "active";
const nowMs = useActiveNowMs(isActive && props.item.startedAtMs !== undefined);
const duration = isActive
? props.item.startedAtMs === undefined
? undefined
: Math.max(0, nowMs - props.item.startedAtMs)
: props.item.durationMs;
const label = isActive
? duration === undefined
? "Working..."
: `Working for ${formatDuration(duration, "precise")}...`
: duration === undefined
? "Worked"
: `Worked for ${formatDuration(duration, "precise")}`;

return (
<button
Expand All @@ -26,7 +53,7 @@ export function WorkBundleMessage(props: WorkBundleMessageProps): React.ReactEle
aria-expanded={props.expanded}
onClick={props.onToggle}
>
<span className="min-w-0 truncate">{label}</span>
<span className="counter-nums min-w-0 truncate">{label}</span>
<ExpandIcon expanded={props.expanded} className="text-muted shrink-0">
â–¶
</ExpandIcon>
Expand Down
Loading
Loading