diff --git a/apps/web/src/components/cloud-agent-next/CloudAgentWorkspaceTabs.test.ts b/apps/web/src/components/cloud-agent-next/CloudAgentWorkspaceTabs.test.ts index b314f22143..ce030b6565 100644 --- a/apps/web/src/components/cloud-agent-next/CloudAgentWorkspaceTabs.test.ts +++ b/apps/web/src/components/cloud-agent-next/CloudAgentWorkspaceTabs.test.ts @@ -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(/]*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'; diff --git a/apps/web/src/components/cloud-agent-next/CloudChatPage.tsx b/apps/web/src/components/cloud-agent-next/CloudChatPage.tsx index cd4b9713f7..4556a5f44f 100644 --- a/apps/web/src/components/cloud-agent-next/CloudChatPage.tsx +++ b/apps/web/src/components/cloud-agent-next/CloudChatPage.tsx @@ -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 { @@ -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); @@ -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 @@ -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; @@ -1223,6 +1231,7 @@ export default function CloudChatPage({ dynamicMessages={dynamicMessages} pendingMessages={pendingMessages} preparationByMessageId={preparationByMessageId} + commitsAfterMessage={commitsAfterMessage} getChildMessages={getChildMessages} onOpenChildSession={handleOpenTopLevelChildSession} onOpenPreparationDetails={handleOpenPreparationDetails} diff --git a/apps/web/src/components/cloud-agent-next/CommitCard.tsx b/apps/web/src/components/cloud-agent-next/CommitCard.tsx new file mode 100644 index 0000000000..944fd8b5f7 --- /dev/null +++ b/apps/web/src/components/cloud-agent-next/CommitCard.tsx @@ -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 ( +
+
+

{subject || 'Empty commit message'}

+ {body && ( +

{body}

+ )} + {commit.commitMessageTruncated && ( +

Message truncated.

+ )} +
+
+ {commit.commitHash} +
+ + {notPushed && ( + <> + + Not pushed + + )} +
+
+
+ ); +} + +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 ( + { + setTooltipOpen(false); + setPopoverOpen(open); + }} + > + + + + + + + {!popoverOpen && ( + + + + )} + + 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" + > + + + + ); +} diff --git a/apps/web/src/components/cloud-agent-next/ConversationMessages.test.ts b/apps/web/src/components/cloud-agent-next/ConversationMessages.test.ts index 69e7867fcf..7ddba66b1a 100644 --- a/apps/web/src/components/cloud-agent-next/ConversationMessages.test.ts +++ b/apps/web/src/components/cloud-agent-next/ConversationMessages.test.ts @@ -3,6 +3,7 @@ import { renderToStaticMarkup } from 'react-dom/server'; import { atom } from 'jotai'; import type { PreparationAttempt, + SessionCommit, SessionManager, StandaloneQuestion, StandaloneSuggestion, @@ -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; @@ -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 }); @@ -108,8 +111,14 @@ function toolPart( function renderConversation( staticMessages: StoredMessage[], - overrides: Partial> = {} + overrides: Partial> & { + commits?: readonly SessionCommit[]; + } = {} ): string { + const { commits = [], ...props } = overrides; + const commitsAfterMessage = + props.commitsAfterMessage ?? + commitsByMessageAnchor([...staticMessages, ...(props.dynamicMessages ?? [])], commits); return renderToStaticMarkup( React.createElement(ConversationMessages, { active: true, @@ -119,7 +128,8 @@ function renderConversation( pendingMessages: new Map(), preparationByMessageId: new Map(), onOpenPreparationDetails: jest.fn(), - ...overrides, + commitsAfterMessage, + ...props, }) ); } @@ -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

'); + expect(html).toContain('Keep the complete body accessible.

'); + 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\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('<script>not markup</script>'); + expect(details).not.toContain('