Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 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
22 changes: 4 additions & 18 deletions ui/server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ import userRoutes from './routes/user.js';
import pluginsRoutes from './routes/plugins.js';
import messagesRoutes from './routes/messages.js';
import { closeMemoryServices, startMemoryScheduler, stopMemoryScheduler } from './services/memoryService.js';
import { createNormalizedMessage } from './pilotdeck-message.js';
import { createNormalizedMessage, createOptimisticUserFrames } from './pilotdeck-message.js';
import { startEnabledPluginServers, stopAllPlugins, getPluginPort } from './utils/plugin-process-manager.js';
import { initializeDatabase, sessionNamesDb, applyCustomSessionNames, userDb } from './database/db.js';
import { configureWebPush } from './services/vapid-keys.js';
Expand Down Expand Up @@ -2474,25 +2474,11 @@ function handleChatConnection(ws, request) {
if (userVisibleInput) {
const nowIso = new Date().toISOString();
const provider = data.options?.providerHint || 'pilotdeck';
const optimisticUserFrame = createNormalizedMessage({
id: `local_ws_user_${crypto.randomUUID()}`,
const [optimisticUserFrame, optimisticStatusFrame] = createOptimisticUserFrames({
sessionId: commandSessionId,
provider,
kind: 'text',
role: 'user',
content: userVisibleInput,
...(Array.isArray(data.options?.attachments) && data.options.attachments.length > 0
? { attachments: data.options.attachments }
: {}),
timestamp: nowIso,
});
const optimisticStatusFrame = createNormalizedMessage({
id: `local_ws_status_${crypto.randomUUID()}`,
sessionId: commandSessionId,
provider,
kind: 'status',
text: 'Processing',
canInterrupt: true,
userVisibleInput,
options: data.options,
timestamp: nowIso,
});
// The submitting tab already rendered its optimistic user row.
Expand Down
7 changes: 6 additions & 1 deletion ui/server/pilotdeck-bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -1137,7 +1137,7 @@ export async function runChatViaGateway(
);
}

const runId = randomUUID();
const runId = resolveTurnRunId(options?.runId);
if (!staleRunId) {
state.runId = runId;
state.active = true;
Expand Down Expand Up @@ -1338,6 +1338,11 @@ export async function runChatViaGateway(
}
}

export function resolveTurnRunId(value) {
const requestedRunId = typeof value === 'string' ? value.trim() : '';
return requestedRunId || randomUUID();
}

async function recordGatewayStatusMessage(gateway, { sessionKey, turnId, projectKey, event, text, detail }) {
if (!gateway?.recordAgentStatusMessage) return;
try {
Expand Down
13 changes: 13 additions & 0 deletions ui/server/pilotdeck-bridge.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,22 @@ import {
getFallbackSessionActivity,
isGatewayUnavailableError,
isTerminalAlwaysOnTurnEvent,
resolveTurnRunId,
uiFilesToAttachments,
} from './pilotdeck-bridge.js';

describe('turn run identity', () => {
it('reuses a non-empty client run id', () => {
expect(resolveTurnRunId(' run-user-1 ')).toBe('run-user-1');
});

it('generates a UUID when a legacy client omits the run id', () => {
expect(resolveTurnRunId(undefined)).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
);
});
});

describe('web attachment conversion', () => {
it('marks uploaded files with the web channel key', () => {
expect(uiFilesToAttachments([{
Expand Down
44 changes: 44 additions & 0 deletions ui/server/pilotdeck-message.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,47 @@ export function createNormalizedMessage(fields) {
provider: fields.provider || 'pilotdeck',
};
}

export function createOptimisticUserFrames({
sessionId,
provider = 'pilotdeck',
userVisibleInput,
options = {},
timestamp = new Date().toISOString(),
}) {
const runId = typeof options.runId === 'string'
? options.runId.trim() || undefined
: undefined;
const images = Array.isArray(options.images)
? options.images
.map((image) => typeof image === 'string' ? image : image?.data)
.filter((image) => typeof image === 'string')
: [];

return [
createNormalizedMessage({
id: `local_ws_user_${crypto.randomUUID()}`,
sessionId,
provider,
kind: 'text',
role: 'user',
content: userVisibleInput,
...(runId ? { runId } : {}),
...(Array.isArray(options.attachments) && options.attachments.length > 0
? { attachments: options.attachments }
: {}),
...(images.length > 0 ? { images } : {}),
timestamp,
}),
createNormalizedMessage({
id: `local_ws_status_${crypto.randomUUID()}`,
sessionId,
provider,
kind: 'status',
text: 'Processing',
canInterrupt: true,
...(runId ? { runId } : {}),
timestamp,
}),
];
}
38 changes: 38 additions & 0 deletions ui/server/pilotdeck-message.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest';
import { createOptimisticUserFrames } from './pilotdeck-message.js';

describe('sibling optimistic user frames', () => {
it('preserves the command run id, images, and attachments', () => {
const [userFrame, statusFrame] = createOptimisticUserFrames({
sessionId: 'web:session-1',
userVisibleInput: 'Review these.',
timestamp: '2026-08-18T00:00:00.000Z',
options: {
runId: 'run-user-1',
images: [{ data: 'data:image/png;base64,one' }],
attachments: [{ name: 'report.pdf', path: '/tmp/report.pdf' }],
},
});

expect(userFrame).toMatchObject({
kind: 'text',
role: 'user',
runId: 'run-user-1',
images: ['data:image/png;base64,one'],
attachments: [{ name: 'report.pdf', path: '/tmp/report.pdf' }],
});
expect(statusFrame).toMatchObject({
kind: 'status',
runId: 'run-user-1',
});
});

it('keeps legacy sibling frames identity-less when the command has no run id', () => {
const [userFrame] = createOptimisticUserFrames({
sessionId: 'web:session-1',
userVisibleInput: 'Continue.',
});

expect(userFrame.runId).toBeUndefined();
});
});
4 changes: 4 additions & 0 deletions ui/src/components/chat/hooks/useChatComposerState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { getDraftInputStorageKey, safeLocalStorage } from '../utils/chatStorage'
import { buildAttachmentPathNote } from '../utils/attachmentNotes';
import {
createTemporarySessionId,
createUserTurnRunId,
getNotificationSessionSummary,
isTemporarySessionId,
startSessionCommand,
Expand Down Expand Up @@ -956,12 +957,14 @@ export function useChatComposerState({

const effectiveSessionId = submitTargetSessionId;
const sessionToActivate = effectiveSessionId || optimisticSessionId;
const runId = createUserTurnRunId();

const userMessage: ChatMessage = {
type: 'user',
content: userVisibleInput,
images: uploadedImages as any,
attachments: [...uploadedFiles, ...documentReferenceAttachments] as any,
runId,
timestamp: new Date(),
};

Expand Down Expand Up @@ -1018,6 +1021,7 @@ export function useChatComposerState({
sendMessage,
selectedProject,
command: messageContent,
runId,
userVisibleInput,
sessionId: effectiveSessionId,
temporarySessionId: sessionToActivate,
Expand Down
27 changes: 26 additions & 1 deletion ui/src/components/chat/hooks/useChatSessionState.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,30 @@
import { describe, expect, it } from 'vitest';
import { resolveConversationScrollTop } from './useChatSessionState';
import type { SessionProvider } from '../../../types/app';
import type { ChatMessage } from '../types/types';
import { chatMessageToNormalized, resolveConversationScrollTop } from './useChatSessionState';

describe('chatMessageToNormalized', () => {
it('preserves user turn identity on optimistic rows', () => {
const message: ChatMessage = {
type: 'user',
content: 'Continue.',
runId: 'run-user-1',
turnId: 'turn-user-1',
timestamp: new Date('2026-08-18T00:00:00.000Z'),
};

expect(chatMessageToNormalized(
message,
'web:session-1',
'pilotdeck' as SessionProvider,
)).toMatchObject({
kind: 'text',
role: 'user',
runId: 'run-user-1',
turnId: 'turn-user-1',
});
});
});

describe('resolveConversationScrollTop', () => {
it('keeps a conversation pinned to the bottom when it was near the bottom', () => {
Expand Down
11 changes: 9 additions & 2 deletions ui/src/components/chat/hooks/useChatSessionState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ export function getStreamContentKey(messages: ChatMessage[]): string {
/* Helper: Convert a ChatMessage to a NormalizedMessage for the store */
/* ------------------------------------------------------------------ */

function chatMessageToNormalized(
export function chatMessageToNormalized(
msg: ChatMessage,
sessionId: string,
provider: SessionProvider,
Expand All @@ -167,7 +167,14 @@ function chatMessageToNormalized(
: typeof msg.timestamp === 'number'
? new Date(msg.timestamp).toISOString()
: String(msg.timestamp);
const base = { id, sessionId, timestamp: ts, provider };
const base = {
id,
sessionId,
timestamp: ts,
provider,
...(msg.runId ? { runId: msg.runId } : {}),
...(msg.turnId ? { turnId: msg.turnId } : {}),
};

if (msg.isToolUse) {
return {
Expand Down
52 changes: 51 additions & 1 deletion ui/src/components/chat/utils/attachmentNotes.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ describe('attachment path notes', () => {
'',
'',
marker,
'- 报告.xlsx: .tmp/chat-attachments/run/1-报告.xlsx',
'- attachment-json: {"name":"报告.xlsx","path":".tmp/chat-attachments/run/1-报告.xlsx"}',
'[End files attached by user]',
'',
].join('\n'));
Expand All @@ -42,6 +42,56 @@ describe('attachment path notes', () => {
}]);
});

it('preserves a colon in the attachment filename', () => {
const filePath = '/tmp/1-report__final.pdf';
const parsed = parseUserAttachmentNote([
'Review this report',
'',
marker,
`- report: final.pdf: ${filePath}`,
'[End files attached by user]',
].join('\n'));

expect(parsed.attachments).toEqual([{
name: 'report: final.pdf',
path: filePath,
mimeType: 'application/pdf',
}]);
});

it('round trips colons in both attachment names and paths', () => {
const parsed = parseUserAttachmentNote([
'Review this report',
buildAttachmentPathNote([{
name: 'report: final.pdf',
path: '/tmp/project: docs/report: final.pdf',
}]),
].join(''));

expect(parsed).toEqual({
content: 'Review this report',
attachments: [{
name: 'report: final.pdf',
path: '/tmp/project: docs/report: final.pdf',
mimeType: 'application/pdf',
}],
});
});

it('round trips an end marker substring inside a JSON attachment path', () => {
const filePath = '/tmp/[End files attached by user]/report.pdf';
const parsed = parseUserAttachmentNote([
'Review this report',
buildAttachmentPathNote([{ name: 'report.pdf', path: filePath }]),
].join(''));

expect(parsed.attachments).toEqual([{
name: 'report.pdf',
path: filePath,
mimeType: 'application/pdf',
}]);
});

it.each([
['PDF metadata', '[PDF attachment: C:\\work\\brief.pdf, 42 bytes]'],
['inline text content', '<attachment path="C:\\work\\notes.txt">'],
Expand Down
Loading