From 878f18e6b077b5a7b2fa3f2cb45b57bbbf6cd11b Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Wed, 18 Feb 2026 13:59:31 +0000 Subject: [PATCH 1/3] Implement five backlog UX fixes across workspace UI --- apps/web/package.json | 4 +- .../src/components/CommandPaletteButton.tsx | 45 ++++ apps/web/src/components/FileBrowserButton.tsx | 12 +- apps/web/src/components/FileViewerPanel.tsx | 197 +++++++++++++++++- apps/web/src/components/GitChangesButton.tsx | 31 ++- apps/web/src/components/GitChangesPanel.tsx | 8 +- apps/web/src/components/WorkspaceTabStrip.tsx | 5 +- apps/web/src/pages/Workspace.tsx | 165 ++++++++++----- .../command-palette-button.test.tsx | 24 +++ .../components/file-browser-button.test.tsx | 7 + .../components/file-viewer-panel.test.tsx | 41 ++++ .../components/git-changes-button.test.tsx | 12 ++ .../components/git-changes-panel.test.tsx | 27 +++ .../components/workspace-tab-strip.test.tsx | 14 +- apps/web/tests/unit/pages/workspace.test.tsx | 180 +++++++++++++++- pnpm-lock.yaml | 6 + .../2026-02-17-close-last-terminal-tab.md | 0 .../2026-02-17-markdown-file-rendering.md | 0 ...026-02-17-mobile-command-palette-access.md | 0 .../2026-02-17-pwa-in-app-navigation.md | 0 .../2026-02-17-reliable-git-status-badge.md | 0 21 files changed, 699 insertions(+), 79 deletions(-) create mode 100644 apps/web/src/components/CommandPaletteButton.tsx create mode 100644 apps/web/tests/unit/components/command-palette-button.test.tsx rename tasks/{backlog => archive}/2026-02-17-close-last-terminal-tab.md (100%) rename tasks/{backlog => archive}/2026-02-17-markdown-file-rendering.md (100%) rename tasks/{backlog => archive}/2026-02-17-mobile-command-palette-access.md (100%) rename tasks/{backlog => archive}/2026-02-17-pwa-in-app-navigation.md (100%) rename tasks/{backlog => archive}/2026-02-17-reliable-git-status-badge.md (100%) diff --git a/apps/web/package.json b/apps/web/package.json index 42db1c0..7ffbc13 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -30,7 +30,9 @@ "prism-react-renderer": "^2.4.1", "react": "^18.0.0", "react-dom": "^18.0.0", - "react-router-dom": "^6.0.0" + "react-markdown": "^10.1.0", + "react-router-dom": "^6.0.0", + "remark-gfm": "^4.0.1" }, "devDependencies": { "@testing-library/jest-dom": "^6.0.0", diff --git a/apps/web/src/components/CommandPaletteButton.tsx b/apps/web/src/components/CommandPaletteButton.tsx new file mode 100644 index 0000000..6a1eb87 --- /dev/null +++ b/apps/web/src/components/CommandPaletteButton.tsx @@ -0,0 +1,45 @@ +import { type CSSProperties, type FC } from 'react'; +import { Search } from 'lucide-react'; + +interface CommandPaletteButtonProps { + onClick: () => void; + disabled?: boolean; + isMobile: boolean; + compactMobile?: boolean; +} + +export const CommandPaletteButton: FC = ({ + onClick, + disabled, + isMobile, + compactMobile = false, +}) => { + const mobileTargetSize = compactMobile ? 36 : 44; + const iconSize = isMobile ? (compactMobile ? 16 : 18) : 16; + + const buttonStyle: CSSProperties = { + background: 'none', + border: 'none', + cursor: disabled ? 'default' : 'pointer', + color: disabled ? 'var(--sam-color-fg-muted)' : 'var(--sam-color-fg-primary)', + opacity: disabled ? 0.5 : 1, + padding: isMobile ? (compactMobile ? '6px' : '8px') : '4px', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + minWidth: isMobile ? mobileTargetSize : 32, + minHeight: isMobile ? mobileTargetSize : 32, + flexShrink: 0, + }; + + return ( + + ); +}; diff --git a/apps/web/src/components/FileBrowserButton.tsx b/apps/web/src/components/FileBrowserButton.tsx index 2b8d21a..d20214b 100644 --- a/apps/web/src/components/FileBrowserButton.tsx +++ b/apps/web/src/components/FileBrowserButton.tsx @@ -5,25 +5,29 @@ interface FileBrowserButtonProps { onClick: () => void; disabled?: boolean; isMobile: boolean; + compactMobile?: boolean; } export const FileBrowserButton: FC = ({ onClick, disabled, isMobile, + compactMobile = false, }) => { + const mobileTargetSize = compactMobile ? 36 : 44; + const iconSize = isMobile ? (compactMobile ? 16 : 18) : 16; const buttonStyle: CSSProperties = { background: 'none', border: 'none', cursor: disabled ? 'default' : 'pointer', color: disabled ? 'var(--sam-color-fg-muted)' : 'var(--sam-color-fg-primary)', opacity: disabled ? 0.5 : 1, - padding: isMobile ? '8px' : '4px', + padding: isMobile ? (compactMobile ? '6px' : '8px') : '4px', display: 'flex', alignItems: 'center', justifyContent: 'center', - minWidth: isMobile ? 44 : 32, - minHeight: isMobile ? 44 : 32, + minWidth: isMobile ? mobileTargetSize : 32, + minHeight: isMobile ? mobileTargetSize : 32, flexShrink: 0, }; @@ -34,7 +38,7 @@ export const FileBrowserButton: FC = ({ aria-label="Browse files" style={buttonStyle} > - + ); }; diff --git a/apps/web/src/components/FileViewerPanel.tsx b/apps/web/src/components/FileViewerPanel.tsx index 3cdaf27..40fc02d 100644 --- a/apps/web/src/components/FileViewerPanel.tsx +++ b/apps/web/src/components/FileViewerPanel.tsx @@ -1,6 +1,16 @@ -import { type CSSProperties, type FC, useCallback, useEffect, useState } from 'react'; +import { + type CSSProperties, + type FC, + type HTMLAttributes, + type ReactNode, + useCallback, + useEffect, + useState, +} from 'react'; import { X } from 'lucide-react'; import { Highlight, themes } from 'prism-react-renderer'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; import { Spinner } from '@simple-agent-manager/ui'; import { getGitFile } from '../lib/api'; @@ -71,6 +81,21 @@ function isBinaryContent(content: string): boolean { return content.includes('\0'); } +type MarkdownViewMode = 'rendered' | 'source'; + +const MARKDOWN_MODE_STORAGE_KEY = 'sam:md-render-mode'; + +function isMarkdownFile(filePath: string): boolean { + const lower = filePath.toLowerCase(); + return lower.endsWith('.md') || lower.endsWith('.mdx'); +} + +function readMarkdownViewMode(): MarkdownViewMode { + if (typeof window === 'undefined') return 'rendered'; + const stored = window.localStorage.getItem(MARKDOWN_MODE_STORAGE_KEY); + return stored === 'source' ? 'source' : 'rendered'; +} + export const FileViewerPanel: FC = ({ workspaceUrl, workspaceId, @@ -87,6 +112,7 @@ export const FileViewerPanel: FC = ({ const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [content, setContent] = useState(null); + const [markdownMode, setMarkdownMode] = useState(() => readMarkdownViewMode()); const fetchFile = useCallback(async () => { setLoading(true); @@ -120,7 +146,12 @@ export const FileViewerPanel: FC = ({ return () => window.removeEventListener('keydown', handleKeyDown); }, [onClose]); + useEffect(() => { + window.localStorage.setItem(MARKDOWN_MODE_STORAGE_KEY, markdownMode); + }, [markdownMode]); + const fileName = filePath.split('/').pop() ?? filePath; + const markdownFile = isMarkdownFile(filePath); const language = detectLanguage(filePath); const binary = content !== null && isBinaryContent(content); @@ -179,6 +210,34 @@ export const FileViewerPanel: FC = ({ {fileName} + {markdownFile && ( +
+ + +
+ )} + {hasGitChanges && onViewDiff && ( ); }; diff --git a/apps/web/src/components/GitChangesPanel.tsx b/apps/web/src/components/GitChangesPanel.tsx index 6cc275f..05dd4d6 100644 --- a/apps/web/src/components/GitChangesPanel.tsx +++ b/apps/web/src/components/GitChangesPanel.tsx @@ -11,6 +11,8 @@ interface GitChangesPanelProps { isMobile: boolean; onClose: () => void; onSelectFile: (filePath: string, staged: boolean) => void; + onStatusChange?: (status: GitStatusData) => void; + onStatusFetchError?: () => void; } const STATUS_LABELS: Record = { @@ -49,6 +51,8 @@ export const GitChangesPanel: FC = ({ isMobile, onClose, onSelectFile, + onStatusChange, + onStatusFetchError, }) => { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -65,12 +69,14 @@ export const GitChangesPanel: FC = ({ try { const result = await getGitStatus(workspaceUrl, workspaceId, token, worktree ?? undefined); setData(result); + onStatusChange?.(result); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to fetch git status'); + onStatusFetchError?.(); } finally { setLoading(false); } - }, [workspaceUrl, workspaceId, token, worktree]); + }, [workspaceUrl, workspaceId, token, worktree, onStatusChange, onStatusFetchError]); useEffect(() => { fetchStatus(); diff --git a/apps/web/src/components/WorkspaceTabStrip.tsx b/apps/web/src/components/WorkspaceTabStrip.tsx index e9fe284..eb01c92 100644 --- a/apps/web/src/components/WorkspaceTabStrip.tsx +++ b/apps/web/src/components/WorkspaceTabStrip.tsx @@ -32,8 +32,6 @@ interface WorkspaceTabStripProps { onReorder?: (fromIndex: number, toIndex: number) => void; /** Slot for the create menu (+) button area */ createMenuSlot: ReactNode; - /** Tab ID that should not show a close button (e.g. default terminal) */ - unclosableTabId?: string; } const MAX_TAB_NAME_LENGTH = 50; @@ -48,7 +46,6 @@ export function WorkspaceTabStrip({ onRename, onReorder, createMenuSlot, - unclosableTabId, }: WorkspaceTabStripProps) { const [hoveredTabId, setHoveredTabId] = useState(null); const [editingTabId, setEditingTabId] = useState(null); @@ -212,7 +209,7 @@ export function WorkspaceTabStrip({ active={activeTabId === tab.id} hovered={hoveredTabId === tab.id} isEditing={editingTabId === tab.id} - canClose={tab.id !== unclosableTabId} + canClose isMobile={isMobile} editValue={editValue} editInputRef={editingTabId === tab.id ? editInputRef : undefined} diff --git a/apps/web/src/pages/Workspace.tsx b/apps/web/src/pages/Workspace.tsx index bd69bc0..df87bbb 100644 --- a/apps/web/src/pages/Workspace.tsx +++ b/apps/web/src/pages/Workspace.tsx @@ -20,6 +20,7 @@ import { WorkspaceTabStrip, type WorkspaceTabItem } from '../components/Workspac import { WorktreeSelector } from '../components/WorktreeSelector'; import { MoreVertical, X } from 'lucide-react'; import { GitChangesButton } from '../components/GitChangesButton'; +import { CommandPaletteButton } from '../components/CommandPaletteButton'; import { GitChangesPanel } from '../components/GitChangesPanel'; import { GitDiffView } from '../components/GitDiffView'; import { FileBrowserButton } from '../components/FileBrowserButton'; @@ -79,7 +80,12 @@ type WorkspaceTab = badge?: string; }; -const DEFAULT_TERMINAL_TAB_ID = '__default-terminal__'; +const GIT_STATUS_POLL_INTERVAL_MS = 30_000; +const GIT_STATUS_RETRY_DELAYS_MS = [750, 1500]; + +function countGitChanges(status: GitStatusData): number { + return status.staged.length + status.unstaged.length + status.untracked.length; +} function workspaceTabStatusColor(tab: WorkspaceTab): string { if (tab.kind === 'terminal') { @@ -151,6 +157,7 @@ export function Workspace() { const [terminalError, setTerminalError] = useState(null); const [gitChangeCount, setGitChangeCount] = useState(0); const [gitStatus, setGitStatus] = useState(null); + const [gitStatusStale, setGitStatusStale] = useState(false); const [sessionTokenUsages, setSessionTokenUsages] = useState([]); const [viewMode, setViewMode] = useState(viewOverride ?? 'terminal'); const [workspaceEvents, setWorkspaceEvents] = useState([]); @@ -651,18 +658,71 @@ export function Workspace() { [id, workspace?.url, terminalToken, activeWorktree, handleSelectWorktree, refreshWorktrees] ); - // Fetch git change count for the badge (once when terminal is ready) + const applyGitStatus = useCallback((status: GitStatusData) => { + setGitStatus(status); + setGitChangeCount(countGitChanges(status)); + setGitStatusStale(false); + }, []); + + const markGitStatusStale = useCallback(() => { + setGitStatusStale(true); + }, []); + + // Keep git status badge fresh: retry initial load and poll periodically. useEffect(() => { - if (!workspace?.url || !terminalToken || !id || !isRunning) return; - getGitStatus(workspace.url, id, terminalToken, activeWorktree ?? undefined) - .then((data) => { - setGitStatus(data); - setGitChangeCount(data.staged.length + data.unstaged.length + data.untracked.length); - }) - .catch(() => { - // Silently fail — badge just won't show a count - }); - }, [workspace?.url, terminalToken, id, isRunning, activeWorktree]); + if (!workspace?.url || !terminalToken || !id || !isRunning) { + setGitStatus(null); + setGitChangeCount(0); + setGitStatusStale(false); + return; + } + + const workspaceId = id; + const workspaceUrl = workspace.url; + let disposed = false; + + const fetchGitStatus = async (retryOnFailure: boolean) => { + const delays = retryOnFailure ? GIT_STATUS_RETRY_DELAYS_MS : []; + const attempts = delays.length + 1; + for (let attempt = 0; attempt < attempts; attempt += 1) { + try { + const status = await getGitStatus( + workspaceUrl, + workspaceId, + terminalToken, + activeWorktree ?? undefined + ); + if (!disposed) applyGitStatus(status); + return; + } catch { + if (attempt === attempts - 1) { + if (!disposed) markGitStatusStale(); + return; + } + const delay = delays[attempt] ?? 0; + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + }; + + void fetchGitStatus(true); + const interval = setInterval(() => { + void fetchGitStatus(false); + }, GIT_STATUS_POLL_INTERVAL_MS); + + return () => { + disposed = true; + clearInterval(interval); + }; + }, [ + workspace?.url, + terminalToken, + id, + isRunning, + activeWorktree, + applyGitStatus, + markGitStatusStale, + ]); const configuredAgents = useMemo( () => agentOptions.filter((agent) => agent.configured && agent.supportsAcp), @@ -794,9 +854,7 @@ export function Workspace() { params.set('view', 'terminal'); params.delete('sessionId'); navigate(`/workspaces/${id}?${params.toString()}`, { replace: true }); - if (tab.sessionId !== DEFAULT_TERMINAL_TAB_ID) { - multiTerminalRef.current?.activateSession(tab.sessionId); - } + multiTerminalRef.current?.activateSession(tab.sessionId); return; } @@ -804,12 +862,26 @@ export function Workspace() { }; const handleCloseWorkspaceTab = (tab: WorkspaceTab) => { + if (activeTabId === tab.id) { + const closeIndex = workspaceTabs.findIndex((candidate) => candidate.id === tab.id); + const remainingTabs = workspaceTabs.filter((candidate) => candidate.id !== tab.id); + if (remainingTabs.length > 0) { + const fallbackIndex = closeIndex >= 0 ? Math.min(closeIndex, remainingTabs.length - 1) : 0; + handleSelectWorkspaceTab(remainingTabs[fallbackIndex]!); + } else { + setViewMode('terminal'); + setActiveTerminalSessionId(null); + const params = new URLSearchParams(searchParams); + params.set('view', 'terminal'); + params.delete('sessionId'); + navigate(`/workspaces/${id}?${params.toString()}`, { replace: true }); + } + } + tabOrder.removeTab(tab.id); if (tab.kind === 'terminal') { - if (tab.sessionId !== DEFAULT_TERMINAL_TAB_ID) { - multiTerminalRef.current?.closeSession(tab.sessionId); - } + multiTerminalRef.current?.closeSession(tab.sessionId); return; } @@ -820,26 +892,9 @@ export function Workspace() { const defaultAgentName = defaultAgentId ? (agentNameById.get(defaultAgentId) ?? null) : null; const visibleTerminalTabs = useMemo(() => { - if (terminalTabs.length > 0) { - return terminalTabs; - } - if (!isRunning || !featureFlags.multiTerminal) { - return []; - } - return [ - { - id: DEFAULT_TERMINAL_TAB_ID, - name: 'Terminal 1', - status: terminalError - ? 'error' - : terminalLoading - ? 'connecting' - : wsUrl - ? 'connected' - : 'disconnected', - }, - ]; - }, [featureFlags.multiTerminal, isRunning, terminalError, terminalLoading, terminalTabs, wsUrl]); + if (!isRunning || !featureFlags.multiTerminal) return []; + return terminalTabs; + }, [featureFlags.multiTerminal, isRunning, terminalTabs]); const workspaceTabs = useMemo(() => { const terminalSessionTabs: WorkspaceTab[] = visibleTerminalTabs.map((session) => ({ @@ -1345,7 +1400,6 @@ export function Workspace() { onRename={handleRenameWorkspaceTab} onReorder={tabOrder.reorderTab} createMenuSlot={createMenuContent} - unclosableTabId={`terminal:${DEFAULT_TERMINAL_TAB_ID}`} /> ) : null; @@ -1394,11 +1448,11 @@ export function Workspace() { style={{ display: 'flex', alignItems: 'center', - padding: isMobile ? '0 4px 0 4px' : '0 12px', + padding: isMobile ? '0 2px' : '0 12px', height: isMobile ? '44px' : '40px', backgroundColor: 'var(--sam-color-bg-surface)', borderBottom: '1px solid var(--sam-color-border-default)', - gap: isMobile ? '4px' : '10px', + gap: isMobile ? '2px' : '10px', flexShrink: 0, }} > @@ -1452,7 +1506,7 @@ export function Workspace() { whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', - maxWidth: isMobile ? '140px' : undefined, + maxWidth: isMobile ? '120px' : undefined, }} > {workspace?.displayName || workspace?.name} @@ -1525,7 +1579,11 @@ export function Workspace() { {/* File browser button */} {isRunning && terminalToken && ( - + )} {/* Git changes button */} @@ -1534,6 +1592,17 @@ export function Workspace() { onClick={handleOpenGitChanges} changeCount={gitChangeCount} isMobile={isMobile} + compactMobile={isMobile} + isStale={gitStatusStale} + /> + )} + + {/* Mobile command palette access */} + {isRunning && terminalToken && isMobile && ( + setShowCommandPalette(true)} + isMobile + compactMobile /> )} @@ -1547,16 +1616,16 @@ export function Workspace() { border: 'none', cursor: 'pointer', color: 'var(--sam-color-fg-muted)', - padding: '8px', + padding: '6px', display: 'flex', alignItems: 'center', justifyContent: 'center', - minWidth: 44, - minHeight: 44, + minWidth: 36, + minHeight: 36, flexShrink: 0, }} > - + )} @@ -1758,6 +1827,8 @@ export function Workspace() { isMobile={isMobile} onClose={handleCloseGitPanel} onSelectFile={handleNavigateToGitDiff} + onStatusChange={applyGitStatus} + onStatusFetchError={markGitStatusStale} /> )} {gitParam === 'diff' && gitFileParam && terminalToken && workspace?.url && id && ( diff --git a/apps/web/tests/unit/components/command-palette-button.test.tsx b/apps/web/tests/unit/components/command-palette-button.test.tsx new file mode 100644 index 0000000..4a9b650 --- /dev/null +++ b/apps/web/tests/unit/components/command-palette-button.test.tsx @@ -0,0 +1,24 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { CommandPaletteButton } from '../../../src/components/CommandPaletteButton'; + +describe('CommandPaletteButton', () => { + it('renders button with accessible label', () => { + render(); + expect(screen.getByRole('button', { name: 'Open command palette' })).toBeInTheDocument(); + }); + + it('calls onClick when pressed', () => { + const onClick = vi.fn(); + render(); + fireEvent.click(screen.getByRole('button', { name: 'Open command palette' })); + expect(onClick).toHaveBeenCalledOnce(); + }); + + it('uses compact mobile size when enabled', () => { + render(); + const button = screen.getByRole('button', { name: 'Open command palette' }); + expect(button.style.minWidth).toBe('36px'); + expect(button.style.minHeight).toBe('36px'); + }); +}); diff --git a/apps/web/tests/unit/components/file-browser-button.test.tsx b/apps/web/tests/unit/components/file-browser-button.test.tsx index 655d867..bbfb8b9 100644 --- a/apps/web/tests/unit/components/file-browser-button.test.tsx +++ b/apps/web/tests/unit/components/file-browser-button.test.tsx @@ -33,4 +33,11 @@ describe('FileBrowserButton', () => { expect(button.style.minWidth).toBe('32px'); expect(button.style.minHeight).toBe('32px'); }); + + it('supports compact mobile touch targets', () => { + render(); + const button = screen.getByRole('button'); + expect(button.style.minWidth).toBe('36px'); + expect(button.style.minHeight).toBe('36px'); + }); }); diff --git a/apps/web/tests/unit/components/file-viewer-panel.test.tsx b/apps/web/tests/unit/components/file-viewer-panel.test.tsx index e54b408..8c7a557 100644 --- a/apps/web/tests/unit/components/file-viewer-panel.test.tsx +++ b/apps/web/tests/unit/components/file-viewer-panel.test.tsx @@ -24,6 +24,7 @@ const defaultProps = { describe('FileViewerPanel', () => { beforeEach(() => { vi.clearAllMocks(); + localStorage.clear(); }); it('shows spinner while loading', () => { @@ -137,4 +138,44 @@ describe('FileViewerPanel', () => { fireEvent.click(screen.getByText('View Diff')); expect(onViewDiff).toHaveBeenCalledWith('src/main.ts', false); }); + + it('shows markdown render/source toggle for markdown files', async () => { + mocks.getGitFile.mockResolvedValue({ content: '# Title\n\nSome text.' }); + render(); + + await waitFor(() => { + expect(screen.getByLabelText('Show rendered markdown')).toBeInTheDocument(); + expect(screen.getByLabelText('Show markdown source')).toBeInTheDocument(); + }); + }); + + it('renders markdown by default and can switch to source view', async () => { + mocks.getGitFile.mockResolvedValue({ content: '# Heading' }); + render(); + + await waitFor(() => { + expect(screen.getByTestId('rendered-markdown')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Heading' })).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByLabelText('Show markdown source')); + + await waitFor(() => { + expect(screen.queryByTestId('rendered-markdown')).not.toBeInTheDocument(); + expect(screen.getByText('#')).toBeInTheDocument(); + expect(screen.getByText('Heading')).toBeInTheDocument(); + }); + }); + + it('persists markdown mode preference in localStorage', async () => { + mocks.getGitFile.mockResolvedValue({ content: '# Heading' }); + render(); + + await waitFor(() => { + expect(screen.getByLabelText('Show markdown source')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByLabelText('Show markdown source')); + expect(localStorage.getItem('sam:md-render-mode')).toBe('source'); + }); }); diff --git a/apps/web/tests/unit/components/git-changes-button.test.tsx b/apps/web/tests/unit/components/git-changes-button.test.tsx index 7e5c703..e165c51 100644 --- a/apps/web/tests/unit/components/git-changes-button.test.tsx +++ b/apps/web/tests/unit/components/git-changes-button.test.tsx @@ -59,4 +59,16 @@ describe('GitChangesButton', () => { expect(button.style.minWidth).toBe('32px'); expect(button.style.minHeight).toBe('32px'); }); + + it('uses compact mobile sizing when compactMobile is enabled', () => { + render( {}} isMobile compactMobile />); + const button = screen.getByLabelText('View git changes'); + expect(button.style.minWidth).toBe('36px'); + expect(button.style.minHeight).toBe('36px'); + }); + + it('shows stale status label when git status is stale', () => { + render( {}} isMobile={false} isStale />); + expect(screen.getByLabelText('View git changes (status may be stale)')).toBeInTheDocument(); + }); }); diff --git a/apps/web/tests/unit/components/git-changes-panel.test.tsx b/apps/web/tests/unit/components/git-changes-panel.test.tsx index 4dd37b9..5f87adf 100644 --- a/apps/web/tests/unit/components/git-changes-panel.test.tsx +++ b/apps/web/tests/unit/components/git-changes-panel.test.tsx @@ -165,4 +165,31 @@ describe('GitChangesPanel', () => { fireEvent.click(screen.getByText('Staged')); expect(screen.queryByText('a.ts')).not.toBeInTheDocument(); }); + + it('propagates status updates to parent callback on success', async () => { + const onStatusChange = vi.fn(); + const status = { + staged: [{ path: 'a.ts', status: 'M' }], + unstaged: [], + untracked: [], + }; + mocks.getGitStatus.mockResolvedValue(status); + + render(); + + await waitFor(() => { + expect(onStatusChange).toHaveBeenCalledWith(status); + }); + }); + + it('notifies parent when status refresh fails', async () => { + const onStatusFetchError = vi.fn(); + mocks.getGitStatus.mockRejectedValue(new Error('Network error')); + + render(); + + await waitFor(() => { + expect(onStatusFetchError).toHaveBeenCalled(); + }); + }); }); diff --git a/apps/web/tests/unit/components/workspace-tab-strip.test.tsx b/apps/web/tests/unit/components/workspace-tab-strip.test.tsx index 7c1f061..fcd1884 100644 --- a/apps/web/tests/unit/components/workspace-tab-strip.test.tsx +++ b/apps/web/tests/unit/components/workspace-tab-strip.test.tsx @@ -30,7 +30,6 @@ describe('WorkspaceTabStrip', () => { onClose: vi.fn(), onRename: vi.fn(), createMenuSlot:
+
, - unclosableTabId: undefined as string | undefined, }; beforeEach(() => { @@ -88,16 +87,11 @@ describe('WorkspaceTabStrip', () => { expect(defaultProps.onClose).toHaveBeenCalledTimes(1); }); - it('does not render close button for unclosable tab', () => { - render(); - - // The terminal tab should not have a close button - const closeButtons = screen.queryAllByRole('button', { name: /Close Terminal 1/ }); - expect(closeButtons).toHaveLength(0); + it('renders close button for terminal tabs', () => { + render(); - // But chat tabs should still have close buttons - const chatCloseButtons = screen.getAllByRole('button', { name: /Stop/ }); - expect(chatCloseButtons.length).toBeGreaterThan(0); + const closeTerminalButton = screen.getByRole('button', { name: 'Close Terminal 1' }); + expect(closeTerminalButton).toBeInTheDocument(); }); // ── Rename tests ── diff --git a/apps/web/tests/unit/pages/workspace.test.tsx b/apps/web/tests/unit/pages/workspace.test.tsx index 40dd4fe..cf36877 100644 --- a/apps/web/tests/unit/pages/workspace.test.tsx +++ b/apps/web/tests/unit/pages/workspace.test.tsx @@ -23,6 +23,12 @@ const mocks = vi.hoisted(() => ({ getAgentSettings: vi.fn(), saveAgentSettings: vi.fn(), useAcpSession: vi.fn(), + featureFlags: { + multiTerminal: false, + conversationView: false, + mobileOptimized: true, + debugMode: false, + }, })); vi.mock('../../../src/lib/api', () => ({ @@ -60,10 +66,62 @@ vi.mock('../../../src/lib/api', () => ({ getClientErrorsApiUrl: () => 'https://api.example.com/api/client-errors', })); -vi.mock('@simple-agent-manager/terminal', () => ({ - Terminal: () =>
terminal
, - MultiTerminal: () =>
multi-terminal
, -})); +vi.mock('@simple-agent-manager/terminal', async () => { + const React = await import('react'); + const MultiTerminal = React.forwardRef(({ onSessionsChange }: any, ref: any) => { + const [sessions, setSessions] = React.useState([ + { id: 'term-1', name: 'Terminal 1', status: 'connected' }, + ]); + const [activeSessionId, setActiveSessionId] = React.useState('term-1'); + const counterRef = React.useRef(1); + + React.useEffect(() => { + onSessionsChange?.(sessions, activeSessionId); + }, [sessions, activeSessionId, onSessionsChange]); + + React.useImperativeHandle( + ref, + () => ({ + createSession: () => { + counterRef.current += 1; + const next = counterRef.current; + const sessionId = `term-${next}`; + setSessions((prev: any[]) => [ + ...prev, + { id: sessionId, name: `Terminal ${next}`, status: 'connected' }, + ]); + setActiveSessionId(sessionId); + return sessionId; + }, + closeSession: (sessionId: string) => { + setSessions((prev: any[]) => { + const next = prev.filter((session) => session.id !== sessionId); + setActiveSessionId(next[0]?.id ?? null); + return next; + }); + }, + activateSession: (sessionId: string) => { + setActiveSessionId(sessionId); + }, + renameSession: (sessionId: string, name: string) => { + setSessions((prev: any[]) => + prev.map((session) => (session.id === sessionId ? { ...session, name } : session)) + ); + }, + focus: vi.fn(), + }), + [] + ); + + return
multi-terminal
; + }); + MultiTerminal.displayName = 'MockMultiTerminal'; + + return { + Terminal: () =>
terminal
, + MultiTerminal, + }; +}); vi.mock('@simple-agent-manager/acp-client', () => ({ useAcpMessages: () => ({ @@ -82,6 +140,10 @@ vi.mock('../../../src/components/UserMenu', () => ({ UserMenu: () =>
, })); +vi.mock('../../../src/config/features', () => ({ + useFeatureFlags: () => mocks.featureFlags, +})); + import { Workspace } from '../../../src/pages/Workspace'; function LocationProbe() { @@ -130,6 +192,10 @@ function setMobileViewport() { describe('Workspace page', () => { beforeEach(() => { vi.clearAllMocks(); + mocks.featureFlags.multiTerminal = false; + mocks.featureFlags.conversationView = false; + mocks.featureFlags.mobileOptimized = true; + mocks.featureFlags.debugMode = false; mocks.useAcpSession.mockReturnValue({ state: 'no_session', @@ -200,7 +266,7 @@ describe('Workspace page', () => { url: 'https://ws-ws-123.example.com', }); mocks.getGitStatus.mockResolvedValue({ staged: [], unstaged: [], untracked: [] }); - mocks.getFileIndex.mockResolvedValue({ files: [] }); + mocks.getFileIndex.mockResolvedValue([]); mocks.getWorktrees.mockResolvedValue({ worktrees: [ { @@ -244,6 +310,57 @@ describe('Workspace page', () => { }); }); + describe('multi-terminal tab lifecycle', () => { + it('closing active terminal tab focuses a running chat tab', async () => { + mocks.featureFlags.multiTerminal = true; + mocks.listAgentSessions.mockResolvedValue([ + { + id: 'sess-1', + workspaceId: 'ws-123', + status: 'running', + label: 'Claude Chat', + createdAt: '2026-02-08T00:10:00.000Z', + updatedAt: '2026-02-08T00:10:00.000Z', + }, + ]); + + renderWorkspace('/workspaces/ws-123', true); + + expect(await screen.findByRole('tab', { name: 'Terminal tab: Terminal 1' })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'Chat tab: Claude Chat' })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Close Terminal 1' })); + + await waitFor(() => { + const probe = screen.getByTestId('location-probe').textContent ?? ''; + expect(probe).toContain('view=conversation'); + expect(probe).toContain('sessionId=sess-1'); + }); + expect(screen.queryByRole('tab', { name: 'Terminal tab: Terminal 1' })).not.toBeInTheDocument(); + }); + + it('allows creating a new terminal from + menu after closing the last terminal tab', async () => { + mocks.featureFlags.multiTerminal = true; + mocks.listAgentSessions.mockResolvedValue([]); + + renderWorkspace('/workspaces/ws-123', true); + + expect(await screen.findByRole('tab', { name: 'Terminal tab: Terminal 1' })).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Close Terminal 1' })); + + await waitFor(() => { + expect(screen.queryByRole('tab', { name: /Terminal tab:/ })).not.toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole('button', { name: 'Create terminal or chat session' })); + fireEvent.click(screen.getByRole('button', { name: 'Terminal' })); + + await waitFor(() => { + expect(screen.getByRole('tab', { name: /Terminal tab: Terminal/ })).toBeInTheDocument(); + }); + }); + }); + it('renders workspace detail with terminal and session sidebar', async () => { renderWorkspace('/workspaces/ws-123'); @@ -450,6 +567,42 @@ describe('Workspace page', () => { expect(await screen.findByDisplayValue('Renamed Workspace')).toBeInTheDocument(); }); + it('retries initial git status fetch and updates the header badge when retry succeeds', async () => { + mocks.getGitStatus + .mockRejectedValueOnce(new Error('temporary failure')) + .mockResolvedValueOnce({ + staged: [{ path: 'src/app.ts', status: 'M' }], + unstaged: [], + untracked: [], + }); + + renderWorkspace('/workspaces/ws-123'); + await screen.findByText('Workspace A'); + + await waitFor(() => { + expect(mocks.getGitStatus).toHaveBeenCalledTimes(2); + }); + + const gitButton = screen.getByRole('button', { name: 'View git changes' }); + expect(gitButton).toHaveTextContent('1'); + }); + + it('marks git status as stale in the header when refresh fails', async () => { + mocks.getGitStatus.mockRejectedValue(new Error('still failing')); + + renderWorkspace('/workspaces/ws-123'); + await screen.findByText('Workspace A'); + + await waitFor( + () => { + expect( + screen.getByRole('button', { name: 'View git changes (status may be stale)' }) + ).toBeInTheDocument(); + }, + { timeout: 5000 } + ); + }); + it('uses the only configured agent when creating a chat session from the + menu', async () => { mocks.createAgentSession.mockResolvedValue({ id: 'sess-new', @@ -573,6 +726,23 @@ describe('Workspace page', () => { expect(screen.getByRole('button', { name: 'Open workspace menu' })).toBeInTheDocument(); }); + it('shows command palette button on mobile viewport', async () => { + setMobileViewport(); + renderWorkspace('/workspaces/ws-123'); + await screen.findByText('Workspace A'); + + expect(screen.getByRole('button', { name: 'Open command palette' })).toBeInTheDocument(); + }); + + it('opens command palette when mobile button is tapped', async () => { + setMobileViewport(); + renderWorkspace('/workspaces/ws-123'); + await screen.findByText('Workspace A'); + + fireEvent.click(screen.getByRole('button', { name: 'Open command palette' })); + expect(screen.getByRole('dialog', { name: 'Command palette' })).toBeInTheDocument(); + }); + it('opens overlay with rename and events sections when menu button is clicked', async () => { mocks.listWorkspaceEvents.mockResolvedValue({ events: [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 729db8d..99fada4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -154,9 +154,15 @@ importers: react-dom: specifier: ^18.0.0 version: 18.3.1(react@18.3.1) + react-markdown: + specifier: ^10.1.0 + version: 10.1.0(@types/react@18.3.27)(react@18.3.1) react-router-dom: specifier: ^6.0.0 version: 6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + remark-gfm: + specifier: ^4.0.1 + version: 4.0.1 devDependencies: '@testing-library/jest-dom': specifier: ^6.0.0 diff --git a/tasks/backlog/2026-02-17-close-last-terminal-tab.md b/tasks/archive/2026-02-17-close-last-terminal-tab.md similarity index 100% rename from tasks/backlog/2026-02-17-close-last-terminal-tab.md rename to tasks/archive/2026-02-17-close-last-terminal-tab.md diff --git a/tasks/backlog/2026-02-17-markdown-file-rendering.md b/tasks/archive/2026-02-17-markdown-file-rendering.md similarity index 100% rename from tasks/backlog/2026-02-17-markdown-file-rendering.md rename to tasks/archive/2026-02-17-markdown-file-rendering.md diff --git a/tasks/backlog/2026-02-17-mobile-command-palette-access.md b/tasks/archive/2026-02-17-mobile-command-palette-access.md similarity index 100% rename from tasks/backlog/2026-02-17-mobile-command-palette-access.md rename to tasks/archive/2026-02-17-mobile-command-palette-access.md diff --git a/tasks/backlog/2026-02-17-pwa-in-app-navigation.md b/tasks/archive/2026-02-17-pwa-in-app-navigation.md similarity index 100% rename from tasks/backlog/2026-02-17-pwa-in-app-navigation.md rename to tasks/archive/2026-02-17-pwa-in-app-navigation.md diff --git a/tasks/backlog/2026-02-17-reliable-git-status-badge.md b/tasks/archive/2026-02-17-reliable-git-status-badge.md similarity index 100% rename from tasks/backlog/2026-02-17-reliable-git-status-badge.md rename to tasks/archive/2026-02-17-reliable-git-status-badge.md From 7eff4befd65e48e47f77e29b6343a940d4362bd2 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Wed, 18 Feb 2026 14:11:13 +0000 Subject: [PATCH 2/3] Stabilize workspace multi-terminal tab tests for CI --- apps/web/tests/unit/pages/workspace.test.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/web/tests/unit/pages/workspace.test.tsx b/apps/web/tests/unit/pages/workspace.test.tsx index cf36877..f0958bd 100644 --- a/apps/web/tests/unit/pages/workspace.test.tsx +++ b/apps/web/tests/unit/pages/workspace.test.tsx @@ -326,10 +326,10 @@ describe('Workspace page', () => { renderWorkspace('/workspaces/ws-123', true); - expect(await screen.findByRole('tab', { name: 'Terminal tab: Terminal 1' })).toBeInTheDocument(); + expect(await screen.findByRole('button', { name: /Close Terminal/ })).toBeInTheDocument(); expect(screen.getByRole('tab', { name: 'Chat tab: Claude Chat' })).toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: 'Close Terminal 1' })); + fireEvent.click(screen.getByRole('button', { name: /Close Terminal/ })); await waitFor(() => { const probe = screen.getByTestId('location-probe').textContent ?? ''; @@ -345,8 +345,8 @@ describe('Workspace page', () => { renderWorkspace('/workspaces/ws-123', true); - expect(await screen.findByRole('tab', { name: 'Terminal tab: Terminal 1' })).toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: 'Close Terminal 1' })); + expect(await screen.findByRole('button', { name: /Close Terminal/ })).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /Close Terminal/ })); await waitFor(() => { expect(screen.queryByRole('tab', { name: /Terminal tab:/ })).not.toBeInTheDocument(); From d82442e109c436dbe0fbbea63931c495dff552e8 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Wed, 18 Feb 2026 14:19:25 +0000 Subject: [PATCH 3/3] Harden workspace terminal tab close tests for CI --- apps/web/tests/unit/pages/workspace.test.tsx | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/apps/web/tests/unit/pages/workspace.test.tsx b/apps/web/tests/unit/pages/workspace.test.tsx index f0958bd..5b50555 100644 --- a/apps/web/tests/unit/pages/workspace.test.tsx +++ b/apps/web/tests/unit/pages/workspace.test.tsx @@ -189,6 +189,15 @@ function setMobileViewport() { }); } +async function findCloseTerminalButton() { + const closeButtons = await screen.findAllByRole( + 'button', + { name: /Close Terminal/ }, + { timeout: 5_000 } + ); + return closeButtons[0]; +} + describe('Workspace page', () => { beforeEach(() => { vi.clearAllMocks(); @@ -326,10 +335,10 @@ describe('Workspace page', () => { renderWorkspace('/workspaces/ws-123', true); - expect(await screen.findByRole('button', { name: /Close Terminal/ })).toBeInTheDocument(); + expect(await findCloseTerminalButton()).toBeInTheDocument(); expect(screen.getByRole('tab', { name: 'Chat tab: Claude Chat' })).toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: /Close Terminal/ })); + fireEvent.click(await findCloseTerminalButton()); await waitFor(() => { const probe = screen.getByTestId('location-probe').textContent ?? ''; @@ -345,8 +354,8 @@ describe('Workspace page', () => { renderWorkspace('/workspaces/ws-123', true); - expect(await screen.findByRole('button', { name: /Close Terminal/ })).toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: /Close Terminal/ })); + expect(await findCloseTerminalButton()).toBeInTheDocument(); + fireEvent.click(await findCloseTerminalButton()); await waitFor(() => { expect(screen.queryByRole('tab', { name: /Terminal tab:/ })).not.toBeInTheDocument();