Skip to content
Open
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
5 changes: 5 additions & 0 deletions client/src/activity-card-state.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,8 @@ test('activity card opens only while a visible process is running', () => {
assert.equal(activityCardShouldOpen({ running: false, hasProcess: true }), false);
assert.equal(activityCardShouldOpen({ running: true, hasProcess: false }), false);
});

test('activity card opens the latest processed activity by default', () => {
assert.equal(activityCardShouldOpen({ running: false, hasProcess: true, latestActivity: true }), true);
assert.equal(activityCardShouldOpen({ running: false, hasProcess: false, latestActivity: true }), false);
});
41 changes: 41 additions & 0 deletions client/src/app-state.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { appReducer, createInitialUiState } from './app/AppState.js';
import { applyPwaTheme } from './app/pwa-theme.js';
import {
createDraftSession,
buildComposerRunStatus,
externalThreadRuntimeById,
localFileApiPath,
localFilePreviewPath,
Expand All @@ -12,6 +13,8 @@ import {
resolveNewConversationProject,
runningByIdWithSelectedActivity,
selectedSessionIsRunning,
sessionMessagesApiPath,
sessionLivePollMessageOptions,
sessionRunBadgeState,
shouldClearRuntimeWhenNoActiveRuns,
shouldDropRunningActivityMissingFromActiveRuns,
Expand Down Expand Up @@ -201,6 +204,30 @@ test('selected desktop activity counts as running for composer controls', () =>
}), false);
});

test('composer run status surfaces the active desktop command', () => {
const status = buildComposerRunStatus([
{
id: 'activity-running-command',
role: 'activity',
status: 'running',
label: '正在处理本地任务',
startedAt: '2026-05-14T06:00:00.000Z',
activities: [
{
kind: 'command_execution',
status: 'running',
label: '正在处理本地任务',
command: 'for i in 1 2 3 4 5 6 7 8; do sleep 5; curl -s http://example.test/api/sim; done'
}
]
}
], true, new Date('2026-05-14T06:00:05.000Z').getTime());

assert.equal(status.running, true);
assert.match(status.label, /^正在运行 for i in 1 2 3 4 5 6 7 8;/);
assert.notEqual(status.label, '正在处理本地任务');
});

test('selected running activity marks the matching sidebar session as running', () => {
const runningById = runningByIdWithSelectedActivity(
{},
Expand Down Expand Up @@ -410,3 +437,17 @@ test('localFilePreviewPath routes local files through the mobile preview page',
'/preview/file?path=%2FUsers%2Fdemo%2Freport.md&token=secret+token'
);
});

test('sessionMessagesApiPath can skip expensive activity projection for initial loads', () => {
assert.equal(
sessionMessagesApiPath('thread-1', { activity: false }),
'/api/sessions/thread-1/messages?limit=120'
);
});

test('sessionLivePollMessageOptions keeps frequent running polls lightweight', () => {
assert.deepEqual(sessionLivePollMessageOptions(0), { activity: true });
assert.deepEqual(sessionLivePollMessageOptions(1), { activity: false });
assert.deepEqual(sessionLivePollMessageOptions(5), { activity: false });
assert.deepEqual(sessionLivePollMessageOptions(6), { activity: true });
});
22 changes: 21 additions & 1 deletion client/src/app-websocket-events.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import {
shouldRefreshCurrentSessionAfterReconnect,
shouldRenderActivityMessageForPayload,
shouldRenderAssistantMessageForPayload,
shouldRenderStatusMessageForPayload
shouldRenderStatusMessageForPayload,
shouldTrackStatusRuntime
} from './app/useAppWebSocket.js';

test('desktop IPC status updates drive runtime without rendering local activity cards', () => {
Expand Down Expand Up @@ -125,6 +126,25 @@ test('headless fallback activity and assistant updates are read from the thread
);
});

test('queued turn updates refresh the queue without rendering chat activity', () => {
const payload = {
type: 'status-update',
kind: 'turn',
status: 'queued'
};

assert.equal(shouldRenderStatusMessageForPayload(payload), false);
assert.equal(shouldTrackStatusRuntime(payload), false);
assert.equal(
shouldTrackStatusRuntime({
type: 'status-update',
kind: 'reasoning',
status: 'queued'
}),
true
);
});

test('websocket reconnect refresh skips drafts and restores real selected sessions', () => {
assert.equal(shouldRefreshCurrentSessionAfterReconnect({ id: 'thread-1' }), true);
assert.equal(shouldRefreshCurrentSessionAfterReconnect({ id: 'draft-project-1' }), false);
Expand Down
56 changes: 55 additions & 1 deletion client/src/app/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ import { useViewportSizing } from './useViewportSizing.js';
import { applyPwaTheme } from './pwa-theme.js';
import { nextSyncedComposerSettings } from './model-sync.js';
import { rememberSelectedSession } from './selection-persistence.js';
import {
buildBackgroundHandoffMessage,
visibleBackgroundHandoff
} from './background-handoff.js';
import {
buildComposerRunStatus,
emptyContextStatus,
Expand Down Expand Up @@ -102,6 +106,7 @@ export default function App() {
const [threadRuntimeById, setThreadRuntimeById] = useState({});
const [syncing, setSyncing] = useState(false);
const [connectionState, setConnectionState] = useState(() => (getToken() ? 'connecting' : 'disconnected'));
const [backgroundHandoffs, setBackgroundHandoffs] = useState([]);
const wsRef = useRef(null);
const selectedProjectRef = useRef(null);
const selectedSessionRef = useRef(null);
Expand Down Expand Up @@ -415,6 +420,7 @@ export default function App() {
setSessionsByProject,
setMessages,
setContextStatus,
setBackgroundHandoffs,
applyAutoSessionTitle,
notifyFromPayload,
loadQueueDrafts,
Expand Down Expand Up @@ -451,6 +457,7 @@ export default function App() {
});

const {
submitCodexMessage,
handleSubmit,
handleImplementPlan,
handleAdjustPlan,
Expand Down Expand Up @@ -489,9 +496,46 @@ export default function App() {
markSessionCompleteNotice,
markTurnCompleted,
scheduleTurnRefresh,
loadQueueDrafts
loadQueueDrafts,
setBackgroundHandoffs
});

async function handleSyncBackgroundHandoff(handoff) {
const message = buildBackgroundHandoffMessage(handoff);
try {
await submitCodexMessage({
message,
clearComposer: false,
restoreTextOnError: false,
sendMode: 'start',
visibleMessageOverride: message,
codexMessageOverride: message
});
const syncedAt = new Date().toISOString();
setBackgroundHandoffs((current) =>
current.map((item) => (item.id === handoff.id ? { ...item, syncedAt } : item))
);
showToast({
level: 'success',
title: '已同步到桌面',
body: '后台执行摘要已发送到当前线程。'
});
} catch (error) {
showToast({
level: 'error',
title: '同步失败',
body: error.message || '请稍后重试。'
});
}
}

function handleDismissBackgroundHandoff(handoff) {
const syncedAt = new Date().toISOString();
setBackgroundHandoffs((current) =>
current.map((item) => (item.id === handoff.id ? { ...item, syncedAt } : item))
);
}

async function handleGitAction(action) {
if (!selectedProject || selectedRunning) {
return;
Expand Down Expand Up @@ -531,6 +575,11 @@ export default function App() {
syncing
});
const topBarRuntime = selectedRuntime || (selectedRunning ? { status: 'running' } : null);
const visibleHandoff = visibleBackgroundHandoff(backgroundHandoffs, {
selectedProject,
selectedSession,
desktopBridge: status.desktopBridge
});

if (!authenticated) {
return <PairingScreen onPaired={bootstrap} />;
Expand Down Expand Up @@ -581,6 +630,11 @@ export default function App() {
onPair: handleResetPairing,
onStatus: handleShowConnectionStatus
},
backgroundHandoffProps: {
handoff: visibleHandoff,
onSync: handleSyncBackgroundHandoff,
onDismiss: handleDismissBackgroundHandoff
},
toastStackProps: {
toasts,
onDismiss: dismissToast
Expand Down
4 changes: 3 additions & 1 deletion client/src/app/AppShell.jsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import { Composer } from '../composer/Composer.jsx';
import { ChatPane } from '../chat/ChatPane.jsx';
import { ImagePreviewModal } from '../chat/ImagePreview.jsx';
import { ConnectionRecoveryCard, DocsPanel, Drawer, GitPanel, ToastStack, TopBar } from '../panels/index.js';
import { BackgroundHandoffCard, ConnectionRecoveryCard, DocsPanel, Drawer, GitPanel, ToastStack, TopBar } from '../panels/index.js';

export function AppShell({ shellClass, panelProps, drawerProps, chatProps, composerProps }) {
const {
topBarProps,
docsPanelProps,
gitPanelProps,
recoveryCardProps,
backgroundHandoffProps,
toastStackProps,
imagePreviewProps
} = panelProps;
Expand All @@ -20,6 +21,7 @@ export function AppShell({ shellClass, panelProps, drawerProps, chatProps, compo
<DocsPanel {...docsPanelProps} />
<GitPanel {...gitPanelProps} />
<ConnectionRecoveryCard {...recoveryCardProps} />
<BackgroundHandoffCard {...backgroundHandoffProps} />
<ToastStack {...toastStackProps} />
<ChatPane {...chatProps} />
<Composer {...composerProps} />
Expand Down
44 changes: 43 additions & 1 deletion client/src/app/FilePreviewApp.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,15 @@ function fileNameFromPath(value) {
function previewKind(pathValue, contentType) {
const lowerType = String(contentType || '').toLowerCase();
const lowerPath = String(pathValue || '').toLowerCase();
if (lowerType.startsWith('image/') || /\.(?:png|jpe?g|webp|gif|svg|ico)(?:$|[:?#])/i.test(lowerPath)) {
return 'image';
}
if (lowerType.startsWith('video/') || /\.(?:mp4|m4v|mov|webm|ogv)(?:$|[:?#])/i.test(lowerPath)) {
return 'video';
}
if (lowerType.startsWith('audio/') || /\.(?:mp3|m4a|aac|wav|ogg|flac)(?:$|[:?#])/i.test(lowerPath)) {
return 'audio';
}
if (lowerType.includes('pdf') || /\.pdf(?:$|[:?#])/i.test(lowerPath)) {
return 'pdf';
}
Expand All @@ -31,6 +40,10 @@ function previewKind(pathValue, contentType) {
return 'download';
}

function isNativeMediaKind(kind) {
return kind === 'image' || kind === 'video' || kind === 'audio';
}

function stripFrontmatter(value) {
const text = String(value || '');
if (!text.startsWith('---')) {
Expand Down Expand Up @@ -107,6 +120,20 @@ export default function FilePreviewApp() {
}
setState({ loading: true, error: '', text: '', objectUrl: '', pdfData: null, contentType: '', mtimeMs: 0, editable: false });
try {
const pathKind = previewKind(filePath, '');
if (isNativeMediaKind(pathKind) || pathKind === 'pdf') {
setState({
loading: false,
error: '',
text: '',
objectUrl: '',
pdfData: null,
contentType: pathKind === 'pdf' ? 'application/pdf' : '',
mtimeMs: 0,
editable: false
});
return;
}
const response = await fetch(localFileApiPath(filePath, urlToken || getToken()), {
headers: getToken() ? { authorization: `Bearer ${getToken()}` } : {}
});
Expand Down Expand Up @@ -380,9 +407,24 @@ export default function FilePreviewApp() {
spellCheck={false}
/>
) : null}
{!state.loading && !state.error && kind === 'pdf' && state.pdfData ? (
{!state.loading && !state.error && kind === 'pdf' ? (
<PdfPreview data={state.pdfData} fileUrl={rawFileUrl} />
) : null}
{!state.loading && !state.error && kind === 'image' ? (
<div className="file-preview-media-shell">
<img className="file-preview-media" src={rawFileUrl} alt={title} onError={() => setState((current) => ({ ...current, error: '图片加载失败' }))} />
</div>
) : null}
{!state.loading && !state.error && kind === 'video' ? (
<div className="file-preview-media-shell">
<video className="file-preview-media" src={rawFileUrl} controls playsInline preload="metadata" onError={() => setState((current) => ({ ...current, error: '视频加载失败' }))} />
</div>
) : null}
{!state.loading && !state.error && kind === 'audio' ? (
<div className="file-preview-media-shell is-audio">
<audio className="file-preview-audio" src={rawFileUrl} controls preload="metadata" onError={() => setState((current) => ({ ...current, error: '音频加载失败' }))} />
</div>
) : null}
{!state.loading && !state.error && kind === 'download' && state.objectUrl ? (
<a className="file-preview-open" href={state.objectUrl} target="_blank" rel="noreferrer noopener">
<ExternalLink size={16} />
Expand Down
9 changes: 7 additions & 2 deletions client/src/app/PdfPreview.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,12 @@ export function PdfPreview({ data, fileUrl = '' }) {
const [canFitPage, setCanFitPage] = useState(false);
const [error, setError] = useState('');

const source = useMemo(() => (data ? new Uint8Array(data.slice(0)) : null), [data]);
const source = useMemo(() => {
if (data) {
return { data: new Uint8Array(data.slice(0)) };
}
return fileUrl ? { url: fileUrl } : null;
}, [data, fileUrl]);

useEffect(() => {
let cancelled = false;
Expand All @@ -31,7 +36,7 @@ export function PdfPreview({ data, fileUrl = '' }) {
if (!source) {
return undefined;
}
const loadingTask = pdfjs.getDocument({ data: source });
const loadingTask = pdfjs.getDocument(source);
loadingTask.promise
.then((pdf) => {
if (!cancelled) {
Expand Down
Loading