Skip to content
Closed
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
70 changes: 70 additions & 0 deletions src/lib/components/chat/ContinueSessionBanner.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<script lang="ts">
interface Props {
updatedAt: number;
model: string;
onContinue: () => void;
onDismiss: () => void;
}

const { updatedAt, model, onContinue, onDismiss }: Props = $props();

const updatedLabel = $derived(new Date(updatedAt).toLocaleString());
</script>

<div class="continue-banner" role="region" aria-label="Continue previous conversation">
<div class="continue-banner-content">
<strong>Continue your conversation</strong>
<p>Last active on {updatedLabel} · {model}</p>
</div>
<div class="continue-banner-actions">
<button type="button" class="continue-btn" onclick={onContinue}>Continue</button>
<button type="button" class="dismiss-btn" onclick={onDismiss}>Dismiss</button>
</div>
</div>

<style>
.continue-banner {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: var(--sp-2);
border: 1px solid var(--mode-border, var(--border-accent));
background: var(--mode-banner-bg, rgba(137, 87, 229, 0.08));
border-radius: var(--radius-md);
padding: var(--sp-3);
margin-bottom: var(--sp-3);
}

.continue-banner-content {
display: flex;
flex-direction: column;
gap: var(--sp-1);
}

.continue-banner-content p {
margin: 0;
color: var(--fg-muted);
font-size: 0.85rem;
}

.continue-banner-actions {
display: flex;
align-items: center;
gap: var(--sp-2);
}

.continue-btn,
.dismiss-btn {
min-height: 36px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg-overlay);
color: var(--fg);
padding: 0 var(--sp-3);
cursor: pointer;
}

.continue-btn {
border-color: var(--mode-border, var(--border-accent));
}
</style>
24 changes: 24 additions & 0 deletions src/lib/server/chat-state-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
});
40 changes: 39 additions & 1 deletion src/lib/server/chat-state-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
load(userId: string, tabId: string): Promise<PersistedChatState | null>;
Expand All @@ -25,6 +33,8 @@ export interface ChatStateStore {
tabId: string,
updates: Partial<Pick<PersistedChatState, 'sdkSessionId' | 'model' | 'mode'>>
): Promise<void>;
setPrimarySession(userId: string, session: PrimarySessionState): Promise<void>;
getPrimarySession(userId: string): Promise<PrimarySessionState | null>;
}

// ─── Constants ──────────────────────────────────────────────────────────────
Expand All @@ -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';
}
Expand Down Expand Up @@ -135,11 +149,35 @@ export function createChatStateStore(basePath: string): ChatStateStore {
}
}

async function setPrimarySession(userId: string, session: PrimarySessionState): Promise<void> {
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<PrimarySessionState | null> {
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
};
}
1 change: 1 addition & 0 deletions src/lib/server/ws/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
26 changes: 25 additions & 1 deletion src/lib/server/ws/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
}
}
}

Expand Down
29 changes: 28 additions & 1 deletion src/lib/server/ws/message-handlers/chat.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -29,6 +29,26 @@ export async function handleChat(msg: any, ctx: MessageContext): Promise<void> {
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)
Expand All @@ -38,6 +58,13 @@ export async function handleChat(msg: any, ctx: MessageContext): Promise<void> {
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({
Expand Down
2 changes: 2 additions & 0 deletions src/lib/server/ws/message-handlers/mode-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export async function handleSetMode(msg: any, ctx: MessageContext): Promise<void
} else {
connectionEntry.session.registerPermissionHandler(makePermissionHandler(connectionEntry, ctx.userLogin));
}
connectionEntry.mode = mode;

// Note: mode_changed is sent by the SDK event handler (session.mode_changed)
} catch (err: any) {
Expand Down Expand Up @@ -83,6 +84,7 @@ export async function handleSetModel(msg: any, ctx: MessageContext): Promise<voi
...(connectionEntry.reasoningEffort ? { reasoningEffort: connectionEntry.reasoningEffort } : {}),
...(msg.modelCapabilities ? { modelCapabilities: msg.modelCapabilities } : {}),
});
connectionEntry.model = newModel;
// Note: model_changed is sent by the SDK event handler (session.model_change)
} catch (err: any) {
console.error('Model change error:', err.message);
Expand Down
7 changes: 7 additions & 0 deletions src/lib/server/ws/message-handlers/new-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,13 @@ export async function handleNewSession(msg: any, ctx: MessageContext): Promise<v
createdAt: Date.now(),
updatedAt: Date.now(),
}).catch(() => {});
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, {
Expand Down
11 changes: 11 additions & 0 deletions src/lib/server/ws/message-handlers/resume-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
9 changes: 9 additions & 0 deletions src/lib/server/ws/session-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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) => {
Expand Down
1 change: 1 addition & 0 deletions src/lib/server/ws/session-pool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading