diff --git a/src/lib/components/chat/ContinueSessionBanner.svelte b/src/lib/components/chat/ContinueSessionBanner.svelte new file mode 100644 index 0000000..e49a503 --- /dev/null +++ b/src/lib/components/chat/ContinueSessionBanner.svelte @@ -0,0 +1,70 @@ + + +
+
+ Continue your conversation +

Last active on {updatedLabel} · {model}

+
+
+ + +
+
+ + diff --git a/src/lib/server/chat-state-store.test.ts b/src/lib/server/chat-state-store.test.ts index 523961c..d68370e 100644 --- a/src/lib/server/chat-state-store.test.ts +++ b/src/lib/server/chat-state-store.test.ts @@ -254,4 +254,28 @@ describe('ChatStateStore', () => { spy.mockRestore(); }); }); + + describe('primary session metadata', () => { + it('saves and loads the primary session file', async () => { + const store = createChatStateStore(tempDir); + const session = { + tabId: 'tab-2', + sdkSessionId: 'sdk-abc', + model: 'gpt-4.1', + mode: 'interactive', + updatedAt: Date.now() + }; + + await store.setPrimarySession('user-1', session); + const loaded = await store.getPrimarySession('user-1'); + + expect(loaded).toEqual(session); + }); + + it('returns null when no primary session exists', async () => { + const store = createChatStateStore(tempDir); + const loaded = await store.getPrimarySession('missing-user'); + expect(loaded).toBeNull(); + }); + }); }); diff --git a/src/lib/server/chat-state-store.ts b/src/lib/server/chat-state-store.ts index 709924b..f56b45a 100644 --- a/src/lib/server/chat-state-store.ts +++ b/src/lib/server/chat-state-store.ts @@ -15,6 +15,14 @@ export interface PersistedChatState { updatedAt: number; } +export interface PrimarySessionState { + tabId: string; + sdkSessionId: string | null; + model: string; + mode: string; + updatedAt: number; +} + export interface ChatStateStore { save(userId: string, tabId: string, state: PersistedChatState): Promise; load(userId: string, tabId: string): Promise; @@ -25,6 +33,8 @@ export interface ChatStateStore { tabId: string, updates: Partial> ): Promise; + setPrimarySession(userId: string, session: PrimarySessionState): Promise; + getPrimarySession(userId: string): Promise; } // ─── Constants ────────────────────────────────────────────────────────────── @@ -38,6 +48,10 @@ function statePath(basePath: string, userId: string, tabId: string): string { return join(basePath, userId, `${tabId}.json`); } +function primarySessionPath(basePath: string, userId: string): string { + return join(basePath, userId, 'primary-session-metadata.json'); +} + function isEnoent(err: unknown): boolean { return err instanceof Error && (err as NodeJS.ErrnoException).code === 'ENOENT'; } @@ -135,11 +149,35 @@ export function createChatStateStore(basePath: string): ChatStateStore { } } + async function setPrimarySession(userId: string, session: PrimarySessionState): Promise { + try { + const filePath = primarySessionPath(basePath, userId); + await atomicWrite(filePath, JSON.stringify(session)); + } catch (err) { + console.error(`[chat-state-store] setPrimarySession failed for ${userId}:`, err); + } + } + + async function getPrimarySession(userId: string): Promise { + try { + const filePath = primarySessionPath(basePath, userId); + const content = await readFile(filePath, 'utf-8'); + return JSON.parse(content) as PrimarySessionState; + } catch (err) { + if (!isEnoent(err)) { + console.error(`[chat-state-store] getPrimarySession failed for ${userId}:`, err); + } + return null; + } + } + return { save, load, delete: del, appendMessage, - updateMetadata + updateMetadata, + setPrimarySession, + getPrimarySession }; } diff --git a/src/lib/server/ws/constants.ts b/src/lib/server/ws/constants.ts index f8ac950..dcb7269 100644 --- a/src/lib/server/ws/constants.ts +++ b/src/lib/server/ws/constants.ts @@ -30,6 +30,7 @@ export const HEARTBEAT_INTERVAL = 30_000; // connection, while still detecting truly dead sockets promptly. export const MAX_MISSED_PINGS = 3; export const UPLOAD_DIR_PREFIX = join(tmpdir(), 'copilot-uploads'); +export const PRIMARY_SESSION_MAX_AGE_MS = 24 * 60 * 60 * 1000; export const RATE_LIMITED_TYPES = new Set(['message', 'new_session', 'resume_session', 'compact', 'start_fleet']); export const WS_RATE_LIMIT_MAX = 30; diff --git a/src/lib/server/ws/handler.ts b/src/lib/server/ws/handler.ts index 06530b8..bdae8a9 100644 --- a/src/lib/server/ws/handler.ts +++ b/src/lib/server/ws/handler.ts @@ -11,7 +11,7 @@ import { sessionPool, createPoolEntry, destroyPoolEntry, poolSend, isValidTabId, countUserSessions, evictOldestUserSession, } from './session-pool.js'; -import { VALID_MESSAGE_TYPES, HEARTBEAT_INTERVAL, MAX_MISSED_PINGS, RATE_LIMITED_TYPES, WS_RATE_LIMIT_MAX, WS_RATE_LIMIT_WINDOW_MS } from './constants.js'; +import { VALID_MESSAGE_TYPES, HEARTBEAT_INTERVAL, MAX_MISSED_PINGS, PRIMARY_SESSION_MAX_AGE_MS, RATE_LIMITED_TYPES, WS_RATE_LIMIT_MAX, WS_RATE_LIMIT_WINDOW_MS } from './constants.js'; import { messageHandlers } from './message-handlers/index.js'; import { chatStateStore } from '../chat-state-singleton.js'; import { debug } from '../logger.js'; @@ -207,6 +207,30 @@ export function setupWebSocket( mode: persistedState!.mode, sdkSessionId: persistedState!.sdkSessionId, }); + } else { + try { + const primarySession = await chatStateStore.getPrimarySession(userLogin); + const isRecentPrimarySession = !!( + primarySession && + Date.now() - primarySession.updatedAt < PRIMARY_SESSION_MAX_AGE_MS + ); + if (isRecentPrimarySession) { + const primaryState = await chatStateStore.load(userLogin, primarySession.tabId); + if (primaryState && primaryState.messages.length > 0) { + poolSend(entry, { + type: 'primary_session_available', + tabId: primarySession.tabId, + sdkSessionId: primarySession.sdkSessionId, + model: primarySession.model, + mode: primarySession.mode, + updatedAt: primarySession.updatedAt, + messages: primaryState.messages, + }); + } + } + } catch (err) { + console.error('[WS-SERVER] Primary session lookup failed:', err); + } } } diff --git a/src/lib/server/ws/message-handlers/chat.ts b/src/lib/server/ws/message-handlers/chat.ts index de6ef8b..d4efc64 100644 --- a/src/lib/server/ws/message-handlers/chat.ts +++ b/src/lib/server/ws/message-handlers/chat.ts @@ -1,4 +1,4 @@ -import { poolSend } from '../session-pool.js'; +import { poolSend, sessionPool } from '../session-pool.js'; import { MAX_MESSAGE_LENGTH } from '../constants.js'; import { mapAttachmentsToSdk } from '../attachments.js'; import { resolveFileMentions } from '../file-mentions.js'; @@ -29,6 +29,26 @@ export async function handleChat(msg: any, ctx: MessageContext): Promise { const { prompt, fileAttachments: mentionAttachments } = await resolveFileMentions(content); const allAttachments = [...uploadAttachments, ...mentionAttachments]; + const currentSdkSessionId = connectionEntry.sdkSessionId; + let hasActivePeer = false; + if (currentSdkSessionId) { + for (const [poolKey, peerEntry] of sessionPool) { + if (!poolKey.startsWith(`${ctx.userLogin}:`)) continue; + if (peerEntry === connectionEntry) continue; + if (!peerEntry.isProcessing) continue; + if (peerEntry.sdkSessionId === currentSdkSessionId) { + hasActivePeer = true; + break; + } + } + } + if (hasActivePeer) { + poolSend(connectionEntry, { + type: 'session_taken', + message: 'Another device is currently generating a response for this conversation.', + }); + } + connectionEntry.isProcessing = true; // Persist user message before sending to SDK (fire-and-forget) @@ -38,6 +58,13 @@ export async function handleChat(msg: any, ctx: MessageContext): Promise { timestamp: Date.now(), ...(allAttachments.length ? { attachmentCount: allAttachments.length } : {}), }).catch(() => {}); + chatStateStore.setPrimarySession(ctx.userLogin, { + tabId: rawTabId(ctx), + sdkSessionId: connectionEntry.sdkSessionId, + model: connectionEntry.model ?? 'gpt-4.1', + mode: connectionEntry.mode ?? 'interactive', + updatedAt: Date.now(), + }).catch(() => {}); const sendMode = msg.mode === 'immediate' || msg.mode === 'enqueue' ? msg.mode : undefined; await connectionEntry.session.send({ diff --git a/src/lib/server/ws/message-handlers/mode-model.ts b/src/lib/server/ws/message-handlers/mode-model.ts index 831d958..e11cf90 100644 --- a/src/lib/server/ws/message-handlers/mode-model.ts +++ b/src/lib/server/ws/message-handlers/mode-model.ts @@ -26,6 +26,7 @@ export async function handleSetMode(msg: any, ctx: MessageContext): Promise {}); + chatStateStore.setPrimarySession(ctx.userLogin, { + tabId: rawTabId(ctx), + sdkSessionId: connectionEntry.session?.sessionId ?? null, + model: msg.model ?? 'gpt-4.1', + mode: msg.mode ?? 'interactive', + updatedAt: Date.now(), + }).catch(() => {}); } catch (err: any) { console.error('Session creation error:', err.message); poolSend(connectionEntry, { diff --git a/src/lib/server/ws/message-handlers/resume-session.ts b/src/lib/server/ws/message-handlers/resume-session.ts index c725886..09abfac 100644 --- a/src/lib/server/ws/message-handlers/resume-session.ts +++ b/src/lib/server/ws/message-handlers/resume-session.ts @@ -168,6 +168,17 @@ export async function handleResumeSession(msg: any, ctx: MessageContext): Promis console.warn(`[RESUME] Failed to load session history: ${histErr.message}`); } + connectionEntry.sdkSessionId = sessionId; + connectionEntry.mode = resumedMode; + connectionEntry.model = msg.model || connectionEntry.model || 'gpt-4.1'; + chatStateStore.setPrimarySession(ctx.userLogin, { + tabId: rawTabId(ctx), + sdkSessionId: sessionId, + model: connectionEntry.model ?? 'gpt-4.1', + mode: resumedMode, + updatedAt: Date.now(), + }).catch(() => {}); + poolSend(connectionEntry, { type: 'session_resumed', sessionId }); } catch (err: any) { console.error('Resume session error:', err.message); diff --git a/src/lib/server/ws/session-events.ts b/src/lib/server/ws/session-events.ts index c04c855..bd93dae 100644 --- a/src/lib/server/ws/session-events.ts +++ b/src/lib/server/ws/session-events.ts @@ -89,6 +89,13 @@ export function wireSessionEvents( content: pendingAssistantContent, timestamp: Date.now(), }).catch(() => {}); + chatStateStore.setPrimarySession(userLogin, { + tabId, + sdkSessionId: sessionId ?? entry.sdkSessionId, + model: entry.model ?? 'gpt-4.1', + mode: entry.mode ?? 'interactive', + updatedAt: Date.now(), + }).catch(() => {}); } // Push notification when client is unreachable (WS closed or app backgrounded) @@ -116,6 +123,7 @@ export function wireSessionEvents( poolSend(entry, { type: 'tool_progress', toolCallId: event.data.toolCallId, message: event.data.message }); }); session.on('session.mode_changed', (event: any) => { + entry.mode = event.data.newMode ?? entry.mode; poolSend(entry, { type: 'mode_changed', mode: event.data.newMode }); }); session.on('session.error', (event: any) => { @@ -228,6 +236,7 @@ export function wireSessionEvents( poolSend(entry, { type: 'subagent_deselected', agentName: event.data?.agentName }); }); session.on('session.model_change', (event: any) => { + entry.model = event.data?.model || event.data?.newModel || entry.model; poolSend(entry, { type: 'model_changed', model: event.data?.model || event.data?.newModel, source: 'sdk' }); }); session.on('elicitation.requested', (event: any) => { diff --git a/src/lib/server/ws/session-pool.ts b/src/lib/server/ws/session-pool.ts index aa6d622..ac4a578 100644 --- a/src/lib/server/ws/session-pool.ts +++ b/src/lib/server/ws/session-pool.ts @@ -8,6 +8,7 @@ const TAB_ID_PATTERN = /^[a-z0-9_-]{1,64}$/i; // Control message types that should be prioritized in the buffer (never evicted before data messages) const CONTROL_MESSAGE_TYPES = new Set([ 'connected', 'cold_resume', 'session_created', 'session_resumed', 'session_reconnected', + 'primary_session_available', 'session_taken', 'turn_start', 'turn_end', 'error', 'warning', 'session_shutdown', 'mode_changed', 'model_changed', 'title_changed', 'permission_request', 'user_input_request', diff --git a/src/lib/stores/chat.svelte.ts b/src/lib/stores/chat.svelte.ts index 258bf37..c51fcd4 100644 --- a/src/lib/stores/chat.svelte.ts +++ b/src/lib/stores/chat.svelte.ts @@ -21,11 +21,14 @@ import type { QuotaSnapshots, QuotaSnapshot, SessionUsageTotals, + PrimarySessionAvailableState, } from '$lib/types/index.js'; import { pickPrimaryQuota } from '$lib/types/index.js'; import type { WsStore } from '$lib/stores/ws.svelte.js'; import { notify } from '$lib/utils/notifications.js'; +const SESSION_TAKEN_NOTICE_DURATION_MS = 3000; + export interface ChatStore { // Message state readonly messages: ChatMessage[]; @@ -47,6 +50,8 @@ export interface ChatStore { readonly pendingUserInput: UserInputState | null; readonly pendingElicitation: ElicitationState | null; readonly pendingPermissions: PermissionRequestState[]; + readonly primarySessionAvailable: PrimarySessionAvailableState | null; + readonly sessionTakenNotice: string | null; // Data lists readonly models: Map; @@ -83,6 +88,7 @@ export interface ChatStore { clearPendingPermission(requestId?: string): void; clearPendingUserInput(): void; clearPendingElicitation(): void; + dismissPrimarySession(): void; } let nextId = 0; @@ -113,6 +119,9 @@ export function createChatStore(wsStore: WsStore): ChatStore { let pendingUserInput = $state(null); let pendingElicitation = $state(null); let pendingPermissions = $state([]); + let primarySessionAvailable = $state(null); + let sessionTakenNotice = $state(null); + let sessionTakenTimer: ReturnType | null = null; // Tracks if the current turn involved tool execution (tool approval → tool run). // Used to force-notify on turn_end even when the tab is visible, because the user // had to briefly return to approve the tool and may have switched away again. @@ -208,6 +217,7 @@ export function createChatStore(wsStore: WsStore): ChatStore { break; case 'cold_resume': { + primarySessionAvailable = null; // Restore persisted chat history from server-side storage if (Array.isArray(msg.messages) && msg.messages.length > 0) { const restored: ChatMessage[] = msg.messages @@ -246,6 +256,7 @@ export function createChatStore(wsStore: WsStore): ChatStore { } case 'session_created': + primarySessionAvailable = null; currentModel = msg.model; if (msg.sessionId) currentSessionId = msg.sessionId; plan = { exists: false, content: '' }; @@ -254,6 +265,7 @@ export function createChatStore(wsStore: WsStore): ChatStore { break; case 'session_reconnected': + primarySessionAvailable = null; if (msg.hasSession) { addInfoMessage('Session reconnected'); } @@ -584,6 +596,7 @@ export function createChatStore(wsStore: WsStore): ChatStore { break; case 'session_resumed': + primarySessionAvailable = null; currentSessionId = msg.sessionId; addInfoMessage(`Session resumed: ${msg.sessionId}`); notify('Session restored — ready to continue', { @@ -591,6 +604,28 @@ export function createChatStore(wsStore: WsStore): ChatStore { }); break; + case 'primary_session_available': + primarySessionAvailable = { + tabId: msg.tabId, + sdkSessionId: msg.sdkSessionId, + model: msg.model, + mode: msg.mode, + updatedAt: msg.updatedAt, + messages: msg.messages, + }; + break; + + case 'session_taken': + sessionTakenNotice = msg.message; + if (sessionTakenTimer) { + clearTimeout(sessionTakenTimer); + } + sessionTakenTimer = setTimeout(() => { + sessionTakenNotice = null; + sessionTakenTimer = null; + }, SESSION_TAKEN_NOTICE_DURATION_MS); + break; + case 'session_deleted': sessions = sessions.filter((s) => s.id !== msg.sessionId); addInfoMessage('Session deleted'); @@ -844,6 +879,12 @@ export function createChatStore(wsStore: WsStore): ChatStore { pendingUserInput = null; pendingElicitation = null; pendingPermissions = []; + primarySessionAvailable = null; + sessionTakenNotice = null; + if (sessionTakenTimer) { + clearTimeout(sessionTakenTimer); + sessionTakenTimer = null; + } contextInfo = null; sessionDetail = null; baselineUsedRequests = null; @@ -874,6 +915,10 @@ export function createChatStore(wsStore: WsStore): ChatStore { pendingElicitation = null; } + function dismissPrimarySession(): void { + primarySessionAvailable = null; + } + // ── Queue management ────────────────────────────────────────────────── function addQueuedMessage(content: string, attachments?: Attachment[]): void { @@ -921,6 +966,8 @@ export function createChatStore(wsStore: WsStore): ChatStore { get pendingUserInput() { return pendingUserInput; }, get pendingElicitation() { return pendingElicitation; }, get pendingPermissions() { return pendingPermissions; }, + get primarySessionAvailable() { return primarySessionAvailable; }, + get sessionTakenNotice() { return sessionTakenNotice; }, get models() { return models; }, get tools() { return tools; }, @@ -950,5 +997,6 @@ export function createChatStore(wsStore: WsStore): ChatStore { clearPendingPermission, clearPendingUserInput, clearPendingElicitation, + dismissPrimarySession, }; } diff --git a/src/lib/stores/chat.test.ts b/src/lib/stores/chat.test.ts index 932265d..87487d3 100644 --- a/src/lib/stores/chat.test.ts +++ b/src/lib/stores/chat.test.ts @@ -270,6 +270,42 @@ describe('createChatStore', () => { }); }); + it('stores and dismisses primary session availability and session-taken indicator', () => { + vi.useFakeTimers(); + const store = createChatStore(createWsStoreMock()); + + dispatch(store, { + type: 'primary_session_available', + tabId: 'tab-desktop', + sdkSessionId: 'session-123', + model: 'gpt-4.1', + mode: 'interactive', + updatedAt: 1234, + messages: [{ type: 'user', content: 'hello' }], + }); + expect(store.primarySessionAvailable).toEqual({ + tabId: 'tab-desktop', + sdkSessionId: 'session-123', + model: 'gpt-4.1', + mode: 'interactive', + updatedAt: 1234, + messages: [{ type: 'user', content: 'hello' }], + }); + + dispatch(store, { + type: 'session_taken', + message: 'Another device is currently generating a response.', + }); + expect(store.sessionTakenNotice).toContain('Another device'); + vi.advanceTimersByTime(3000); + expect(store.sessionTakenNotice).toBeNull(); + + store.dismissPrimarySession(); + expect(store.primarySessionAvailable).toBeNull(); + + vi.useRealTimers(); + }); + it('records usage, quota, context, plan, compaction, and reasoning-effort updates', () => { const store = createChatStore(createWsStoreMock()); const initialQuota: QuotaSnapshots = { diff --git a/src/lib/types/server-messages.ts b/src/lib/types/server-messages.ts index d18d52b..969f24b 100644 --- a/src/lib/types/server-messages.ts +++ b/src/lib/types/server-messages.ts @@ -34,6 +34,21 @@ export interface SessionReconnectedMessage { isProcessing?: boolean; } +export interface PrimarySessionAvailableMessage { + type: 'primary_session_available'; + tabId: string; + sdkSessionId: string | null; + model: string; + mode: string; + updatedAt: number; + messages: Array>; +} + +export interface SessionTakenMessage { + type: 'session_taken'; + message: string; +} + export interface TurnStartMessage { type: 'turn_start'; } @@ -572,6 +587,8 @@ export type ServerMessage = | ColdResumeMessage | SessionCreatedMessage | SessionReconnectedMessage + | PrimarySessionAvailableMessage + | SessionTakenMessage | TurnStartMessage | DeltaMessage | TurnEndMessage diff --git a/src/lib/types/state.ts b/src/lib/types/state.ts index fae0a2a..e2570d2 100644 --- a/src/lib/types/state.ts +++ b/src/lib/types/state.ts @@ -44,3 +44,12 @@ export interface PlanState { content: string; path?: string; } + +export interface PrimarySessionAvailableState { + tabId: string; + sdkSessionId: string | null; + model: string; + mode: string; + updatedAt: number; + messages: Array>; +} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 5687868..87d33bc 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -2,6 +2,7 @@ import DeviceFlowLogin from '$lib/components/auth/DeviceFlowLogin.svelte'; import MessageList from '$lib/components/chat/MessageList.svelte'; import ChatInput from '$lib/components/chat/ChatInput.svelte'; + import ContinueSessionBanner from '$lib/components/chat/ContinueSessionBanner.svelte'; import Banner from '$lib/components/layout/Banner.svelte'; import EnvInfo from '$lib/components/layout/EnvInfo.svelte'; import PlanPanel from '$lib/components/plan/PlanPanel.svelte'; @@ -35,6 +36,11 @@ let modelSheetOpen = $state(false); let sessionsLoading = $state(false); let sessionLoading = $state(true); + let pendingNewSessionTimer: ReturnType | null = null; + const hasBrowserLocalStorage = + typeof window !== 'undefined' && typeof window.localStorage?.getItem === 'function'; + const DEVICE_CHAT_ACTIVITY_KEY = 'copilot-has-chatted'; + const PRIMARY_SESSION_WAIT_MS = 150; // Use the confirmed model from the active session; fall back to the user's saved preference // so the TopBar/ModelSheet show the correct model immediately before session_created arrives. @@ -92,15 +98,29 @@ console.log('[PAGE] connected with sdkSessionId, resuming', msg.sdkSessionId); wsStore.resumeSession(msg.sdkSessionId); } else { - // No previous session — show new chat immediately + // No previous session for this tab — create one unless a primary session is offered. sessionLoading = false; - console.log('[PAGE] connected without sdkSessionId, creating new session'); - requestNewSession(); + clearPendingNewSessionTimer(); + const timer = setTimeout(() => { + if (pendingNewSessionTimer !== timer) return; + if (!chatStore.primarySessionAvailable && !wsStore.sessionReady) { + console.log('[PAGE] connected without sdkSessionId, creating new session'); + requestNewSession(); + } + pendingNewSessionTimer = null; + }, PRIMARY_SESSION_WAIT_MS); + pendingNewSessionTimer = timer; } } + if (msg.type === 'primary_session_available' && msg.sdkSessionId && !hasDeviceChatted()) { + clearPendingNewSessionTimer(); + handleContinuePrimarySession(msg.sdkSessionId); + } + // Session fully loaded — clear loading state if (msg.type === 'cold_resume' || msg.type === 'session_created' || msg.type === 'session_resumed' || msg.type === 'session_reconnected') { + clearPendingNewSessionTimer(); sessionLoading = false; } @@ -168,6 +188,7 @@ return () => { console.log('[PAGE] effect cleanup: unsubscribing and disconnecting WS'); + clearPendingNewSessionTimer(); unsub(); wsStore.disconnect(); }; @@ -203,6 +224,23 @@ }); } + function markDeviceAsChatted(): void { + if (hasBrowserLocalStorage) { + localStorage.setItem(DEVICE_CHAT_ACTIVITY_KEY, '1'); + } + } + + function hasDeviceChatted(): boolean { + return hasBrowserLocalStorage && localStorage.getItem(DEVICE_CHAT_ACTIVITY_KEY) === '1'; + } + + function clearPendingNewSessionTimer(): void { + if (pendingNewSessionTimer) { + clearTimeout(pendingNewSessionTimer); + pendingNewSessionTimer = null; + } + } + function handleSend(content: string, attachments?: Attachment[]): void { const trimmed = content.trim(); @@ -215,6 +253,7 @@ return; } chatStore.addUserMessage(content); + markDeviceAsChatted(); wsStore.send({ type: 'start_fleet', prompt }); return; } @@ -228,6 +267,7 @@ return; } chatStore.addUserMessage(content); + markDeviceAsChatted(); wsStore.sendMessage(`Run the following shell command and show me the output:\n\`\`\`\n${command}\n\`\`\``, attachments); return; } @@ -240,6 +280,7 @@ } chatStore.addUserMessage(content, attachments); + markDeviceAsChatted(); wsStore.sendMessage(content, attachments); } @@ -313,6 +354,14 @@ sessionsOpen = false; } + function handleContinuePrimarySession(sessionId: string | null): void { + if (!sessionId) return; + clearPendingNewSessionTimer(); + chatStore.clearMessages(); + chatStore.dismissPrimarySession(); + wsStore.resumeSession(sessionId); + } + function handleOpenSettings(): void { sidebarOpen = false; settingsOpen = true; @@ -381,6 +430,19 @@ {/each} {:else} + {#if chatStore.primarySessionAvailable && chatStore.messages.length === 0} + handleContinuePrimarySession(chatStore.primarySessionAvailable?.sdkSessionId ?? null)} + onDismiss={() => chatStore.dismissPrimarySession()} + /> + {/if} + + {#if chatStore.sessionTakenNotice} +
{chatStore.sessionTakenNotice}
+ {/if} + {#if chatStore.plan.exists} { chatStore.addUserMessage(`/fleet ${prompt}`); + markDeviceAsChatted(); wsStore.send({ type: 'start_fleet', prompt }); }} onNewChat={handleNewChat} @@ -602,6 +665,16 @@ overflow: hidden; } + .session-taken-notice { + margin-bottom: var(--sp-2); + padding: var(--sp-2) var(--sp-3); + border-radius: var(--radius-sm); + border: 1px solid rgba(210, 168, 255, 0.45); + background: rgba(210, 168, 255, 0.09); + color: var(--fg-muted); + font-size: 0.84rem; + } + @media (min-width: 600px) { .terminal { padding: var(--sp-4) var(--sp-5); diff --git a/tests/chat-messaging.spec.ts b/tests/chat-messaging.spec.ts index 93970f7..0ab3190 100644 --- a/tests/chat-messaging.spec.ts +++ b/tests/chat-messaging.spec.ts @@ -39,6 +39,67 @@ test.describe('Chat messaging', () => { }); }); + test('shows continue-session banner when a primary session is available on a known device', async ({ browser }) => { + await withAuthenticatedChat( + browser, + { + autoCreateSession: false, + onConnectMessages: [{ + type: 'primary_session_available', + tabId: 'tab-mobile', + sdkSessionId: 'session-primary-1', + model: 'gpt-4.1', + mode: 'interactive', + updatedAt: Date.now(), + messages: [{ type: 'user', content: 'Hi from desktop' }], + }], + onMessage: (msg, ws) => { + if (msg.type === 'resume_session' && typeof msg.sessionId === 'string') { + ws.send(JSON.stringify({ type: 'session_resumed', sessionId: msg.sessionId })); + } + }, + }, + async (page) => { + await page.evaluate(() => localStorage.setItem('copilot-has-chatted', '1')); + await page.reload(); + + await expect(page.getByRole('region', { name: 'Continue previous conversation' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Continue' })).toBeVisible(); + }, + ); + }); + + test('auto-resumes primary session on a fresh device', async ({ browser }) => { + const outboundMessages: Array> = []; + + await withAuthenticatedChat( + browser, + { + autoCreateSession: false, + onConnectMessages: [{ + type: 'primary_session_available', + tabId: 'tab-desktop', + sdkSessionId: 'session-primary-2', + model: 'gpt-4.1', + mode: 'interactive', + updatedAt: Date.now(), + messages: [{ type: 'assistant', content: 'Welcome back' }], + }], + onMessage: (msg, ws) => { + outboundMessages.push(msg); + if (msg.type === 'resume_session' && typeof msg.sessionId === 'string') { + ws.send(JSON.stringify({ type: 'session_resumed', sessionId: msg.sessionId })); + } + }, + }, + async (_page) => { + await expect.poll( + () => outboundMessages.some((outboundMsg) => outboundMsg.type === 'resume_session' && outboundMsg.sessionId === 'session-primary-2'), + ).toBe(true); + }, + ); + }); + test('sends a message and receives a response', async ({ browser }) => { await withAuthenticatedChat( browser, diff --git a/tests/helpers.ts b/tests/helpers.ts index 8cd5428..b712803 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -151,6 +151,8 @@ export interface MockWsOptions { autoCreateSession?: boolean; /** Whether to auto-respond to list_models */ autoListModels?: boolean; + /** Additional server messages to send immediately after connection */ + onConnectMessages?: Record[]; } /** @@ -164,11 +166,15 @@ export async function mockWebSocket(page: Page, options: MockWsOptions = {}) { defaultModel = 'gpt-4.1', autoCreateSession = true, autoListModels = true, + onConnectMessages = [], } = options; await page.context().routeWebSocket('**/ws**', (ws) => { setTimeout(() => { ws.send(JSON.stringify({ type: 'connected', user: MOCK_USER.login })); + onConnectMessages.forEach((message, index) => { + setTimeout(() => ws.send(JSON.stringify(message)), 10 + index * 10); + }); }, 10); ws.onMessage((data) => {