Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,17 @@ describe('CloudAgentWorkspaceTabs', () => {
expect(html).toContain('Selected workspace panel');
}
});

it('keeps workspace navigation limited to chats, terminals, and saved files', () => {
const html = renderWorkspaceTabs({
terminals: [{ id: 'pty', title: 'Terminal 1', cloudAgentSessionId: 'workspace-one' }],
files: [{ path: 'src/file.ts' }],
});
expect((html.match(/<button\b[^>]*role="tab"/g) ?? []).length).toBe(3);
expect(html).not.toContain('View diff');
expect(html).not.toContain('Commit');
});

it('renders complete grouped chat titles and selects only the current session tab', () => {
const firstTitle = 'Investigate the complete authentication regression across every provider';
const secondTitle = 'Fix the separate billing synchronization flow';
Expand Down
13 changes: 11 additions & 2 deletions apps/web/src/components/cloud-agent-next/CloudChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import type { OrganizationRole } from '@/lib/organizations/organization-types';
import { CloudAgentWorkspaceTabs } from './CloudAgentWorkspaceTabs';
import { WorktreeChangesDrawer } from './WorktreeChanges';
import { WorktreeFilePane } from './WorktreeFilePane';
import { commitsByMessageAnchor, isCommitSummaryRepresented } from './message-presentation';
import { Tabs, TabsContent } from '@/components/ui/tabs';
import { canOpenWorktreeChanges } from './worktree-changes';
import {
Expand Down Expand Up @@ -206,6 +207,7 @@ export default function CloudChatPage({
const activity = useAtomValue(manager.atoms.activity);
const cloudStatus = useAtomValue(manager.atoms.cloudStatus);
const preparationAttempts = useAtomValue(manager.atoms.preparationAttempts);
const commits = useAtomValue(manager.atoms.commits);
const activeQuestion = useAtomValue(manager.atoms.activeQuestion);
const activePermission = useAtomValue(manager.atoms.activePermission);
const activeSuggestion = useAtomValue(manager.atoms.activeSuggestion);
Expand Down Expand Up @@ -276,6 +278,11 @@ export default function CloudChatPage({
const fileScope = JSON.stringify([currentUserId, organizationId, sessionIdFromParams, sessionId]);
const [resolvedFileScope, setResolvedFileScope] = useState(fileScope);
const filesVisible = canOpenChanges && resolvedFileScope === fileScope;
const commitsAfterMessage = useMemo(
() =>
commitsByMessageAnchor([...staticMessages, ...dynamicMessages], filesVisible ? commits : []),
[commits, dynamicMessages, filesVisible, staticMessages]
);
const activeWorkspaceTabId =
!filesVisible && workspaceTabs.activeTabId.startsWith('file:')
? CHAT_TAB_ID
Expand Down Expand Up @@ -1008,8 +1015,9 @@ export default function CloudChatPage({
// A running preparation row already shows live progress inline, so the
// trailing progress row would repeat the same message beneath it.
const visibleStatusIndicator =
statusIndicator?.type === 'progress' &&
preparationAttempts.some(attempt => attempt.status === 'running')
(statusIndicator?.type === 'progress' &&
preparationAttempts.some(attempt => attempt.status === 'running')) ||
isCommitSummaryRepresented(statusIndicator, commitsAfterMessage)
? null
: statusIndicator;

Expand Down Expand Up @@ -1223,6 +1231,7 @@ export default function CloudChatPage({
dynamicMessages={dynamicMessages}
pendingMessages={pendingMessages}
preparationByMessageId={preparationByMessageId}
commitsAfterMessage={commitsAfterMessage}
getChildMessages={getChildMessages}
onOpenChildSession={handleOpenTopLevelChildSession}
onOpenPreparationDetails={handleOpenPreparationDetails}
Expand Down
99 changes: 99 additions & 0 deletions apps/web/src/components/cloud-agent-next/CommitCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
'use client';

import { useState } from 'react';
import type { SessionCommit } from '@kilocode/cloud-agent-sdk';
import { GitCommitHorizontal } from 'lucide-react';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';

export function CommitDetails({ commit }: { commit: SessionCommit }) {
const [subject, ...bodyLines] = commit.commitMessage.split('\n');
const body = bodyLines.join('\n').replace(/^\n+/, '');
const notPushed = commit.pushStatus === 'failed' || commit.pushStatus === 'not_attempted';

return (
<div className="flex flex-col gap-3 wrap-anywhere">
<div className="space-y-2">
<p className="text-sm leading-5 font-medium">{subject || 'Empty commit message'}</p>
{body && (
<p className="text-muted-foreground text-xs leading-5 whitespace-pre-wrap">{body}</p>
)}
{commit.commitMessageTruncated && (
<p className="text-muted-foreground text-xs">Message truncated.</p>
)}
</div>
<div className="text-muted-foreground flex flex-col gap-1 text-xs leading-5">
<code className="text-[11px] select-all">{commit.commitHash}</code>
<div className="flex items-center gap-2">
<time dateTime={commit.committedAt} title={new Date(commit.committedAt).toLocaleString()}>
{new Date(commit.committedAt).toLocaleString(undefined, {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
})}
</time>
{notPushed && (
<>
<span aria-hidden="true">·</span>
<span className="whitespace-nowrap">Not pushed</span>
</>
)}
</div>
</div>
</div>
);
}

export function CommitCard({ commit }: { commit: SessionCommit }) {
const [popoverOpen, setPopoverOpen] = useState(false);
const [tooltipOpen, setTooltipOpen] = useState(false);
const shortHash = commit.commitHash.slice(0, 7);
const subject = commit.commitMessage.split('\n', 1)[0];

return (
<Popover
open={popoverOpen}
onOpenChange={open => {
setTooltipOpen(false);
setPopoverOpen(open);
}}
>
<Tooltip open={tooltipOpen && !popoverOpen} onOpenChange={setTooltipOpen}>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<button
type="button"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring my-1 flex min-h-6 min-w-0 max-w-full items-center gap-1.5 rounded-sm text-left text-xs focus-visible:ring-2 focus-visible:outline-none [@media(any-pointer:coarse)]:min-h-11"
data-commit-hash={commit.commitHash}
aria-label={`Commit ${shortHash} details`}
>
<GitCommitHorizontal className="size-3.5 shrink-0" aria-hidden="true" />
<code className="shrink-0">{shortHash}</code>
<span className="min-w-0 truncate">{subject || 'Empty commit subject'}</span>
</button>
</PopoverTrigger>
</TooltipTrigger>
{!popoverOpen && (
<TooltipContent
side="top"
align="start"
sideOffset={4}
className="max-h-[min(24rem,var(--radix-tooltip-content-available-height))] w-96 max-w-[calc(100vw-2rem)] overflow-y-auto overscroll-contain p-4 text-left text-wrap"
>
<CommitDetails commit={commit} />
</TooltipContent>
)}
</Tooltip>
<PopoverContent
side="top"
align="start"
onCloseAutoFocus={() => queueMicrotask(() => setTooltipOpen(false))}
aria-label={`Commit ${shortHash} details`}
className="max-h-[min(24rem,var(--radix-popover-content-available-height))] w-96 max-w-[calc(100vw-2rem)] overflow-y-auto overscroll-contain p-4"
>
<CommitDetails commit={commit} />
</PopoverContent>
</Popover>
);
}
177 changes: 175 additions & 2 deletions apps/web/src/components/cloud-agent-next/ConversationMessages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { renderToStaticMarkup } from 'react-dom/server';
import { atom } from 'jotai';
import type {
PreparationAttempt,
SessionCommit,
SessionManager,
StandaloneQuestion,
StandaloneSuggestion,
Expand All @@ -11,6 +12,7 @@ import type { AssistantMessage } from '@/types/opencode.gen';
import type { Part, ReasoningPart, StoredMessage, TextPart, ToolPart } from './types';
import type * as ToolCardShellModule from './ToolCardShell';
import { useOptionalManager } from './CloudAgentProvider';
import { CommitDetails } from './CommitCard';

let mockExpanded = false;

Expand All @@ -37,6 +39,7 @@ jest.mock('react-markdown', () => ({
jest.mock('remark-gfm', () => ({ __esModule: true, default: () => undefined }));

import { ConversationMessages } from './ConversationMessages';
import { commitsByMessageAnchor } from './message-presentation';
import { PartRenderer } from './PartRenderer';

Object.assign(globalThis, { React });
Expand Down Expand Up @@ -108,8 +111,14 @@ function toolPart(

function renderConversation(
staticMessages: StoredMessage[],
overrides: Partial<React.ComponentProps<typeof ConversationMessages>> = {}
overrides: Partial<React.ComponentProps<typeof ConversationMessages>> & {
commits?: readonly SessionCommit[];
} = {}
): string {
const { commits = [], ...props } = overrides;
const commitsAfterMessage =
props.commitsAfterMessage ??
commitsByMessageAnchor([...staticMessages, ...(props.dynamicMessages ?? [])], commits);
return renderToStaticMarkup(
React.createElement(ConversationMessages, {
active: true,
Expand All @@ -119,7 +128,8 @@ function renderConversation(
pendingMessages: new Map(),
preparationByMessageId: new Map(),
onOpenPreparationDetails: jest.fn(),
...overrides,
commitsAfterMessage,
...props,
})
);
}
Expand All @@ -134,6 +144,169 @@ beforeEach(() => {
});

describe('ConversationMessages', () => {
const commit: SessionCommit = {
commitHash: 'a'.repeat(40),
commitMessage: 'Fix the actual issue\n\nKeep the complete body accessible.',
messageId: 'assistant-1',
userMessageId: 'user-1',
committedAt: '2026-09-01T10:00:00.000Z',
pushStatus: 'failed',
};

it('renders one compact commit line at its anchor without diff actions, preserving later rows', () => {
const html = renderConversation(
[assistantMessage('assistant-1', [textPart('first', 'First answer')])],
{
dynamicMessages: [assistantMessage('assistant-2', [textPart('second', 'Later answer')])],
commits: [commit, { ...commit }],
}
);
expect(html.match(/data-commit-hash=/g)).toHaveLength(1);
const trigger = buttons(html).find(button => button.includes('data-commit-hash='));
expect(trigger).toContain(`data-commit-hash="${commit.commitHash}"`);
expect(trigger).toContain('Fix the actual issue');
expect(trigger).toContain('truncate');
expect(trigger).toContain('aria-label="Commit aaaaaaa details"');
expect(trigger).toContain('aria-haspopup="dialog"');
expect(trigger).toContain('aria-expanded="false"');
expect(trigger).toContain('focus-visible:ring-2');
expect(trigger).toContain('[@media(any-pointer:coarse)]:min-h-11');
expect(trigger).not.toMatch(/\b(?:border|bg-|flex-wrap)/);
expect(html).not.toContain('View diff');
expect(html).not.toContain('Keep the complete body accessible.');
expect(html).not.toContain('Push failed');
expect(html).not.toContain('may include changes from other chats');
expect(html.indexOf('First answer')).toBeLessThan(html.indexOf('data-commit-hash='));
expect(html.indexOf('data-commit-hash=')).toBeLessThan(html.indexOf('Later answer'));
expect(html.match(/data-message-role="assistant"/g)).toHaveLength(2);
});

it('shares one combined anchor map across transcript chunks without fallback duplicates', () => {
const staticMessages: StoredMessage[] = [
{
info: {
id: 'user-1',
role: 'user',
sessionID: 'ses-1',
time: { created: 0 },
agent: 'code',
model: { providerID: 'test', modelID: 'test' },
},
parts: [],
},
assistantMessage('assistant-1', [textPart('first', 'First answer')]),
];
const dynamicMessages = [
assistantMessage('assistant-2', [textPart('second', 'Second answer')]),
];
const anchoredCommit = { ...commit, messageId: 'assistant-2' };
const commitsAfterMessage = commitsByMessageAnchor(
[...staticMessages, ...dynamicMessages],
[anchoredCommit]
);
const staticHtml = renderConversation(staticMessages, { commitsAfterMessage });
const dynamicHtml = renderConversation([], { dynamicMessages, commitsAfterMessage });
expect(staticHtml).not.toContain('data-commit-hash=');
expect(dynamicHtml.match(/data-commit-hash=/g)).toHaveLength(1);
expect(dynamicHtml.indexOf('Second answer')).toBeLessThan(
dynamicHtml.indexOf('data-commit-hash=')
);
expect(commitsAfterMessage.get('assistant-2')).toEqual([anchoredCommit]);
});

it.each([
['pushed', false],
['failed', true],
['not_attempted', true],
['unknown', false],
] as const)(
'keeps commit metadata and shows only a confirmed non-push for %s',
(pushStatus, showsNotPushed) => {
const html = renderToStaticMarkup(
React.createElement(CommitDetails, { commit: { ...commit, pushStatus } })
);
expect(html).toContain('Fix the actual issue</p>');
expect(html).toContain('Keep the complete body accessible.</p>');
expect(html).toContain(commit.commitHash);
expect(html).toContain(`dateTime="${commit.committedAt}"`);
expect(html.includes('>Not pushed<')).toBe(showsNotPushed);
expect(html).not.toContain('Workspace commit');
expect(html).not.toContain('Push status');
expect(html).not.toContain('>Pushed<');
expect(html).not.toContain('may include changes from other chats');
expect(html).not.toContain('text-destructive');
expect(html).not.toContain('View diff');
}
);

it('keeps long commit messages as plain text in details without embedding them in the trigger name', () => {
const longCommit = {
...commit,
commitMessage: `${'Long subject '.repeat(100)}\n\n<script>not markup</script>\n${'Body '.repeat(1000)}`,
};
const html = renderConversation(
[assistantMessage('assistant-1', [textPart('answer', 'Answer')])],
{ commits: [longCommit] }
);
expect(html).toContain('aria-label="Commit aaaaaaa details"');
expect(html).not.toContain('Body ');
const details = renderToStaticMarkup(
React.createElement(CommitDetails, { commit: longCommit })
);
expect(details).toContain('Body '.repeat(1000));
expect(details).toContain('&lt;script&gt;not markup&lt;/script&gt;');
expect(details).not.toContain('<script>');
});

it('does not append a commit whose known assistant anchor is unloaded', () => {
const html = renderConversation(
[
{
info: {
id: 'user-1',
role: 'user',
sessionID: 'ses-1',
time: { created: 0 },
agent: 'code',
model: { providerID: 'test', modelID: 'test' },
},
parts: [],
},
assistantMessage('assistant-2', [textPart('later', 'Unrelated later answer')]),
],
{ commits: [commit] }
);
expect(html).not.toContain('data-commit-hash=');
expect(html).toContain('Unrelated later answer');
});

it('keeps different full SHAs with the same display prefix and defers saved-message truncation to details', () => {
const html = renderConversation(
[assistantMessage('assistant-1', [textPart('answer', 'Answer')])],
{
commits: [
commit,
{
...commit,
commitHash: `${'a'.repeat(39)}b`,
pushStatus: 'not_attempted',
commitMessageTruncated: true,
},
],
}
);
expect(html.match(/data-commit-hash=/g)).toHaveLength(2);
expect(html).not.toContain('Not pushed');
expect(html).not.toContain('Message truncated.');
const details = renderToStaticMarkup(
React.createElement(CommitDetails, { commit: { ...commit, commitMessageTruncated: true } })
);
expect(details).toContain('Message truncated.');
expect(renderToStaticMarkup(React.createElement(CommitDetails, { commit }))).not.toContain(
'Message truncated.'
);
});

it('shows individual rows in transcript order with collapsed details and metadata after the answer', () => {
const html = renderConversation([
assistantMessage('assistant-1', [
Expand Down
Loading
Loading