Remove verified dead declarations (#2714)

* chore: remove verified dead declarations

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* chore: narrow unused internal exports

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* chore: remove newly exposed dead helpers

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* chore: remove unused deep-link serializer

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* test: drop two tests that assert on copies of the code

mainLayoutMobileSidebarMount read MainLayout.tsx and SessionSidebar.tsx as
strings and asserted on source substrings down to exact indentation, so it
failed on formatting rather than behaviour. useProjectSessionSelection.test
reimplemented the hook's visitNodes logic inside the test file and asserted
against that copy, so it could not observe the hook at all.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* test: repair sync suites that had rotted while unrunnable

No runner executed packages/ui, so these drifted from the source unnoticed:
two imported helpers that are no longer exported, one directory-store stub
predated the session field routeMessage reads, and the WebSocket fake missed
the mandatory url-token mint plus the close event the socket wrapper reads.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* test: stop the web suite failing on timeouts and a hand-copied mock

The Git suites drive a real git binary, so the 5s default made a valid suite
fail differently per run. The gitApiHttp mock listed ~70 export names by hand
and fell behind the source; it now derives every stub from the real module,
which the added shared-UI aliases make resolvable.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* test: run every suite from one command and in CI

packages/ui (232 files) and packages/vscode (22) had no test script at all, CI
ran neither, and 9 vscode files could never run because Node cannot resolve
their extensionless TypeScript imports. Three electron files sat outside every
script list, one of them importing vitest, which that package does not depend
on. A runner gives each file its own process, since these suites keep
module-level singletons and fail by load order when sharing one.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* chore: delete a superseded repro harness and a completed plan

The issue-2638 harness needed lsof, overrode process.platform and spawned real
servers, and nothing referenced it; event-stream/rebind.test.js now covers the
same hub-pinned-to-the-old-port behaviour. The pairing v2 plan described relay
and the pairing UI as out of scope, both of which shipped.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* docs: point at the theme tools and record the github barrel invariant

convert-vscode-theme and harmonize-theme were referenced nowhere, so the
theme-authoring reference now names them. The github barrel is loaded through
await import('./index.js') and destructured per route, which no static report
can see; documenting that is what stops the next cleanup from deleting it.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* test: repair merge drift in bridge and route-registry mocks

upstream/main gained upsertProviderConfig on bridge-system-runtime and a
PATCH scheduled-task route after this branch forked. Their test doubles
were never updated to match:
- bridge-system-runtime.test.js: add upsertProviderConfig to the
  opencodeConfig mock so the import resolves.
- sse-routes.test.js: add app.patch to the route registry stub.

---------

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
This commit is contained in:
Serhii Dziupin
2026-08-13 15:30:54 +03:00
committed by GitHub
co-authored by Serhii Dziupin
parent 61533ed881
commit 86e6a2ae76
65 changed files with 238 additions and 2509 deletions
+3 -7
View File
@@ -3,7 +3,7 @@ import React from 'react';
import { isCapacitorApp } from '@/lib/platform';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { buildDeepLink, parseDeepLink, type DeepLinkIntent, type SessionsFilter, type ViewTarget } from './deepLinks';
import { parseDeepLink, type DeepLinkIntent, type SessionsFilter, type ViewTarget } from './deepLinks';
/**
* Navigation layer for {@link DeepLinkIntent}s — the only place that knows how to *apply* a
@@ -93,13 +93,13 @@ const flush = (): void => {
};
/** Apply an intent now if possible, otherwise stash it until the app is ready / a handler appears. */
export const applyDeepLinkIntent = (intent: DeepLinkIntent): void => {
const applyDeepLinkIntent = (intent: DeepLinkIntent): void => {
pending = intent;
flush();
};
/** Convenience: parse a raw `openchamber://…` URL and apply it. No-op for unrecognised URLs. */
export const applyDeepLinkUrl = (raw: string | null | undefined): void => {
const applyDeepLinkUrl = (raw: string | null | undefined): void => {
const intent = parseDeepLink(raw);
if (intent) {
applyDeepLinkIntent(intent);
@@ -192,7 +192,3 @@ export const useDeepLinkSource = (options: { ready: boolean }): void => {
};
}, []);
};
// Re-export so producers (notifications, future widgets) have one import for the whole vocabulary.
export { buildDeepLink, parseDeepLink };
export type { DeepLinkIntent, SessionsFilter, ViewTarget };
+1 -44
View File
@@ -10,7 +10,7 @@
* context — including, eventually, a tiny encoder shared with the native widget/extension.
*/
export const DEEP_LINK_SCHEME = 'openchamber';
const DEEP_LINK_SCHEME = 'openchamber';
export type SessionsFilter = 'all' | 'attention' | 'recent';
export type ViewTarget = 'files' | 'mcp' | 'instances' | 'update';
@@ -124,46 +124,3 @@ export function parseDeepLink(raw: string | null | undefined): DeepLinkIntent |
return null;
}
}
/**
* Build a canonical `openchamber://…` URL for an intent. Used by anything that needs to hand
* a deep link to iOS — notification payloads, `widgetURL(...)`, Live Activity tap targets —
* so every producer emits the exact shape {@link parseDeepLink} understands.
*/
export function buildDeepLink(intent: DeepLinkIntent): string {
const base = `${DEEP_LINK_SCHEME}://`;
const withQuery = (path: string, params: Record<string, string | undefined>): string => {
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (typeof value === 'string' && value.length > 0) {
search.set(key, value);
}
}
const query = search.toString();
return query ? `${base}${path}?${query}` : `${base}${path}`;
};
switch (intent.type) {
case 'session':
return withQuery(`session/${encodeURIComponent(intent.sessionId)}`, { dir: intent.directory });
case 'new-session':
return withQuery('new', {
dir: intent.directory,
project: intent.projectId,
agent: intent.agent,
model: intent.model,
});
case 'sessions':
return withQuery('sessions', { filter: intent.filter });
case 'status':
return `${base}status`;
case 'settings':
return intent.section ? `${base}settings/${encodeURIComponent(intent.section)}` : `${base}settings`;
case 'changes':
return withQuery(intent.path ? `changes/${intent.path}` : 'changes', {
staged: intent.staged ? 'true' : undefined,
});
case 'view':
return `${base}view/${intent.target}`;
}
}
+7 -7
View File
@@ -164,7 +164,7 @@ type PairingRedeemResponse = {
// URL helpers
// ---------------------------------------------------------------------------
export const normalizeConnectionUrl = (value: string): string => {
const normalizeConnectionUrl = (value: string): string => {
const trimmed = value.trim();
if (!trimmed) return '';
const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`;
@@ -175,7 +175,7 @@ export const normalizeConnectionUrl = (value: string): string => {
return url.toString().replace(/\/+$/, '');
};
export const getConnectionLabel = (url: string): string => {
const getConnectionLabel = (url: string): string => {
try {
return new URL(url).host;
} catch {
@@ -191,7 +191,7 @@ const getConnectionStorageKey = (url: string): string => {
}
};
export const isSameConnectionUrl = (left: string, right: string): boolean =>
const isSameConnectionUrl = (left: string, right: string): boolean =>
getConnectionStorageKey(left) === getConnectionStorageKey(right);
// ---------------------------------------------------------------------------
@@ -201,7 +201,7 @@ export const isSameConnectionUrl = (left: string, right: string): boolean =>
// Stable identity for a relay connection. Also used as the runtime key passed
// to switchRuntimeEndpoint so "is this saved entry the active runtime?" checks
// can compare against getRuntimeKey().
export const relayConnectionRuntimeKey = (relay: MobileRelayConfig): string =>
const relayConnectionRuntimeKey = (relay: MobileRelayConfig): string =>
`relay:${relay.serverId}@${relay.relayUrl.trim()}`;
// Stable, non-fetchable pseudo-URL for a relay-only device (display only).
@@ -790,7 +790,7 @@ export const upsertMobileConnection = async (
return next;
};
export const deleteMobileConnection = async (id: string): Promise<MobileSavedConnection[]> => {
const deleteMobileConnection = async (id: string): Promise<MobileSavedConnection[]> => {
const connections = readConnections();
const removed = connections.find((connection) => connection.id === id) ?? null;
const next = connections.filter((connection) => connection.id !== id);
@@ -1147,7 +1147,7 @@ const establishLiveTransport = async (
// tunnel via runtimeFetch. A transport failure/timeout is transient (the tunnel
// reconnects on its own) and must not masquerade as a revoked session, so only
// an explicit auth rejection reports invalid.
export const validateActiveRuntimeSession = async (input: {
const validateActiveRuntimeSession = async (input: {
url: string;
clientToken?: string | null;
}, options?: { fast?: boolean }): Promise<boolean> => {
@@ -1283,7 +1283,7 @@ let candidateRefreshInFlight = false;
// Only runs for relay-paired connections: their token/runtime key derives from
// the stable relay identity, so rewriting direct URLs cannot orphan the stored
// token. The response must echo the connection's serverId or it is ignored.
export const refreshActiveConnectionCandidates = async (): Promise<CandidateRefreshResult> => {
const refreshActiveConnectionCandidates = async (): Promise<CandidateRefreshResult> => {
if (candidateRefreshInFlight) return 'skipped';
const active = findActiveConnection();
if (!active) {
-7
View File
@@ -1,5 +1,3 @@
import type { ProjectEntry } from '@/lib/api/types';
export const normalizePath = (value?: string | null): string =>
(value || '').replace(/\\/g, '/').replace(/\/+$/g, '');
@@ -9,8 +7,3 @@ export const getProjectLabel = (path: string): string => {
const segments = normalized.split('/').filter(Boolean);
return segments[segments.length - 1]?.replace(/[-_]/g, ' ') || normalized;
};
export const getProjectDisplayLabel = (project: ProjectEntry | null, fallbackDirectory: string): string => {
if (project) return project.label?.trim() || getProjectLabel(project.path);
return getProjectLabel(fallbackDirectory);
};
+1 -1
View File
@@ -70,7 +70,7 @@ const projectLabelForDirectory = (directory: string | null, projects: ProjectEnt
return basename(directory);
};
export const buildMobileWidgetSnapshot = (): MobileWidgetSnapshot => {
const buildMobileWidgetSnapshot = (): MobileWidgetSnapshot => {
const sessions = useGlobalSessionsStore.getState().activeSessions;
const unseenBySession = useNotificationStore.getState().index.session.unseenCount;
const notifyOnSubtasks = useUIStore.getState().notifyOnSubtasks;
@@ -41,7 +41,7 @@ const languageContextField = StateField.define<ComposerLanguageContext>({
},
});
export const EMPTY_CONTEXT: ComposerLanguageContext = {
const EMPTY_CONTEXT: ComposerLanguageContext = {
inputMode: 'normal',
knownAgentNames: new Set(),
confirmedMentions: new Set(),
@@ -90,8 +90,3 @@ export function composerLanguage(initial: ComposerLanguageContext = EMPTY_CONTEX
decorationField,
];
}
/** The context currently in effect, for callers that need to read it back. */
export function readLanguageContext(view: EditorView): ComposerLanguageContext {
return view.state.field(languageContextField);
}
@@ -152,7 +152,7 @@ export const NATIVE_SELECTION_THEME_SPEC = {
},
};
export const composerNativeSelectionTheme = EditorView.theme(NATIVE_SELECTION_THEME_SPEC);
const composerNativeSelectionTheme = EditorView.theme(NATIVE_SELECTION_THEME_SPEC);
/**
* The native-selection arrangement, installed on every device: the theme
@@ -114,5 +114,3 @@ function matchMention(
});
return query === null ? null : { kind: 'mention', query };
}
export type { FileMentionAutocompleteInputSource };
@@ -91,7 +91,7 @@ export function buildImagePasteInsertion(pastedText: string, citationText: strin
* A single-line URL pasted over a selection becomes a markdown link rather
* than replacing the selected text.
*/
export const PASTE_LINK_URL_PATTERN = /^(https?:\/\/|mailto:)\S+$/i;
const PASTE_LINK_URL_PATTERN = /^(https?:\/\/|mailto:)\S+$/i;
/**
* Whether a pasted URL should wrap the selection as `[selected](url)`. A URL
@@ -54,7 +54,7 @@ const getProjectIconColor = (projectColor?: string | null): string | undefined =
projectColor ? PROJECT_COLOR_MAP[projectColor] ?? undefined : undefined;
/** A project's icon (custom image, configured icon, or a folder) plus its name. */
export function ProjectLabel({ project, theme }: { project: DraftTargetProject; theme: Theme }) {
function ProjectLabel({ project, theme }: { project: DraftTargetProject; theme: Theme }) {
const projectIconName = project.icon ? PROJECT_ICON_MAP[project.icon] : null;
const iconColor = getProjectIconColor(project.color);
const fallbackIcon = projectIconName ? (
@@ -58,7 +58,7 @@ const toSelectionNode = (node: Node): SelectionNode | null => {
};
};
export const trimSelectionNodes = (nodes: SelectionNode[]): SelectionNode[] => {
const trimSelectionNodes = (nodes: SelectionNode[]): SelectionNode[] => {
return nodes
.filter((node) => node.type === 'text' || !node.isCodeLineNumber)
.map((node) => node.type === 'text'
@@ -1,48 +0,0 @@
import { describe, expect, test } from 'bun:test';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const mainLayoutSource = readFileSync(
join(__dirname, '..', 'MainLayout.tsx'),
'utf-8',
);
const sessionSidebarSource = readFileSync(
join(__dirname, '..', '..', 'session', 'SessionSidebar.tsx'),
'utf-8',
);
describe('MainLayout mobile SessionSidebar mount (issue #1695 regression guard)', () => {
test('mobile SessionSidebar is not conditionally mounted on mobileLeftDrawerVisible', () => {
const mobileSidebarIndex = mainLayoutSource.indexOf('<SessionSidebar mobileVariant');
expect(mobileSidebarIndex).toBeGreaterThan(-1);
const windowStart = Math.max(0, mobileSidebarIndex - 400);
const precedingWindow = mainLayoutSource.slice(windowStart, mobileSidebarIndex);
expect(/\{\s*mobileLeftDrawerVisible\s*&&\s*\(/.test(precedingWindow)).toBe(false);
expect(precedingWindow.includes('pointer-events-none')).toBe(true);
expect(mainLayoutSource.slice(mobileSidebarIndex, mobileSidebarIndex + 120)).toContain('isVisible={mobileLeftDrawerVisible}');
});
test('desktop SessionSidebar is rendered inside Sidebar without drawer-visibility gating', () => {
const desktopSidebarIndex = mainLayoutSource.indexOf('<SessionSidebar isVisible={isSidebarOpen} />');
expect(desktopSidebarIndex).toBeGreaterThan(-1);
const windowStart = Math.max(0, desktopSidebarIndex - 300);
const precedingWindow = mainLayoutSource.slice(windowStart, desktopSidebarIndex);
expect(precedingWindow).toContain('<Sidebar');
expect(/mobileLeftDrawerVisible\s*&&/.test(precedingWindow)).toBe(false);
});
test('hidden sidebars disable render-only subscriptions and effects', () => {
expect(sessionSidebarSource).toContain('useGitAllBranches(isVisible)');
expect(sessionSidebarSource).toContain('useGitRepoStatusMap(isVisible ? normalizedProjectPaths : EMPTY_STRING_ARRAY)');
expect(sessionSidebarSource).toContain('enabled: isVisible,\n isSessionSearchOpen');
expect(sessionSidebarSource).toContain('enabled: isVisible,\n isDesktopShellRuntime');
expect(sessionSidebarSource).toContain('if (!isVisible) return EMPTY_STRING_ARRAY;');
});
});
@@ -7,7 +7,7 @@ import { useProjectsStore } from '@/stores/useProjectsStore';
const HEX_COLOR_PATTERN = /^#(?:[\da-fA-F]{3}|[\da-fA-F]{6})$/;
export const normalizeProjectIconBackground = (value: string | null | undefined): string | null => {
const normalizeProjectIconBackground = (value: string | null | undefined): string | null => {
if (!value) {
return null;
}
@@ -6,9 +6,9 @@
export const CUSTOM_PROVIDER_NPM = '@ai-sdk/openai-compatible';
export const CUSTOM_PROVIDER_ID = '__custom_provider__';
export const PROVIDER_ID_PATTERN = /^[a-z0-9][a-z0-9-_]*$/;
export const BASE_URL_PATTERN = /^https?:\/\//;
export const ENV_KEY_PATTERN = /^\{env:([^}]+)\}$/;
const PROVIDER_ID_PATTERN = /^[a-z0-9][a-z0-9-_]*$/;
const BASE_URL_PATTERN = /^https?:\/\//;
const ENV_KEY_PATTERN = /^\{env:([^}]+)\}$/;
export type CustomProviderTranslator = (
key: string,
@@ -126,7 +126,7 @@ export const createEmptyCustomProviderForm = (): CustomProviderFormState => ({
headers: [createHeaderRow()],
});
export function parseEnvApiKey(apiKey: string): { env?: string; key?: string } {
function parseEnvApiKey(apiKey: string): { env?: string; key?: string } {
const trimmed = apiKey.trim();
if (!trimmed) {
return {};
@@ -59,7 +59,7 @@ export const SETTINGS_SECTION_TITLE_CLASS =
/** Split-pane sidebar panel title — same level as section titles. */
export const SETTINGS_PANEL_TITLE_CLASS = SETTINGS_SECTION_TITLE_CLASS;
/** L3 — control-group heading inside a section. */
export const SETTINGS_GROUP_TITLE_CLASS =
const SETTINGS_GROUP_TITLE_CLASS =
'typography-settings-group-title text-foreground';
/** L4 — field / control labels. */
export const SETTINGS_FIELD_LABEL_CLASS =
@@ -1,547 +0,0 @@
import React, { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, describe, expect, mock, test } from 'bun:test';
import type { Session } from '@opencode-ai/sdk/v2';
import type { SessionGroup, SessionNode } from '../types';
let currentSessionId: string | null = null;
let newSessionDraftOpen = false;
let isNewWorktreeDialogOpen = false;
mock.module('@/stores/useUIStore', () => ({
useUIStore: Object.assign(
(selector: (state: { isNewWorktreeDialogOpen: boolean }) => unknown) =>
selector({ isNewWorktreeDialogOpen }),
{ getState: () => ({ isNewWorktreeDialogOpen }) },
),
}));
mock.module('@/sync/session-ui-store', () => ({
useSessionUIStore: (selector: (state: {
currentSessionId: string | null;
newSessionDraft: { open: boolean };
}) => unknown) => selector({
currentSessionId,
newSessionDraft: { open: newSessionDraftOpen },
}),
}));
const {
resolveMissingProjectSessionSelection,
ProjectSessionSelectionEffect,
} = await import('./useProjectSessionSelection');
// ---------------------------------------------------------------------------
// Helper: simulate the projectSessionMeta computation from the hook
// (same visitNodes logic as useProjectSessionSelection.ts)
// ---------------------------------------------------------------------------
type ProjectSection = {
project: { id: string; normalizedPath: string };
groups: SessionGroup[];
};
function computeProjectMeta(projectSections: ProjectSection[]) {
const metaByProject = new Map<string, Map<string, { directory: string | null }>>();
const firstSessionByProject = new Map<string, { id: string; directory: string | null }>();
const visitNodes = (
projectId: string,
projectRoot: string,
fallbackDirectory: string | null,
nodes: SessionNode[],
) => {
if (!metaByProject.has(projectId)) {
metaByProject.set(projectId, new Map());
}
const projectMap = metaByProject.get(projectId)!;
nodes.forEach((node) => {
const sessionDirectory = (
node.worktree?.path
?? (node.session as Session & { directory?: string | null }).directory
?? fallbackDirectory
?? projectRoot
).replace(/\\/g, '/').replace(/\/+$/, '');
projectMap.set(node.session.id, { directory: sessionDirectory });
if (!firstSessionByProject.has(projectId)) {
firstSessionByProject.set(projectId, { id: node.session.id, directory: sessionDirectory });
}
if (node.children.length > 0) {
visitNodes(projectId, projectRoot, sessionDirectory, node.children);
}
});
};
projectSections.forEach((section) => {
section.groups.forEach((group) => {
visitNodes(section.project.id, section.project.normalizedPath, group.directory, group.sessions);
});
});
return { metaByProject, firstSessionByProject };
}
// ---------------------------------------------------------------------------
// Test data
// ---------------------------------------------------------------------------
const makeSession = (id: string, directory?: string): Session =>
({ id, directory } as unknown as Session);
const rootSession1 = makeSession('root-session-1', '/workspace/project');
const rootSession2 = makeSession('root-session-2', '/workspace/project');
const worktreeSession1 = makeSession('wt-session-1', '/workspace/project-wt');
const project2Session1 = makeSession('project-2-session-1', '/workspace/project-2');
const project2Session2 = makeSession('project-2-session-2', '/workspace/project-2');
const WORKTREE_PATH = '/workspace/project-wt';
// staleSections: root group only, no worktree group
const staleSections: ProjectSection[] = [
{
project: { id: 'project-1', normalizedPath: '/workspace/project' },
groups: [
{
id: 'root',
label: 'Main',
branch: null,
description: null,
isMain: true,
worktree: null,
directory: '/workspace/project',
sessions: [
{ session: rootSession1, children: [], worktree: null },
{ session: rootSession2, children: [], worktree: null },
],
},
],
},
];
// updatedSections: includes the worktree group
const updatedSections: ProjectSection[] = [
{
project: { id: 'project-1', normalizedPath: '/workspace/project' },
groups: [
{
id: 'root',
label: 'Main',
branch: null,
description: null,
isMain: true,
worktree: null,
directory: '/workspace/project',
sessions: [
{ session: rootSession1, children: [], worktree: null },
{ session: rootSession2, children: [], worktree: null },
],
},
{
id: 'wt-group',
label: 'feature-branch',
branch: 'feature-branch',
description: 'Worktree at ' + WORKTREE_PATH,
isMain: false,
worktree: { path: WORKTREE_PATH, projectDirectory: '/workspace/project', branch: 'feature-branch', label: 'feature-branch' },
directory: WORKTREE_PATH,
sessions: [
{ session: worktreeSession1, children: [], worktree: { path: WORKTREE_PATH, projectDirectory: '/workspace/project', branch: 'feature-branch', label: 'feature-branch' } },
],
},
],
},
];
// project-2Sections: separate project for project-switching tests
const project2Sections: ProjectSection[] = [
{
project: { id: 'project-2', normalizedPath: '/workspace/project-2' },
groups: [
{
id: 'root',
label: 'Main',
branch: null,
description: null,
isMain: true,
worktree: null,
directory: '/workspace/project-2',
sessions: [
{ session: project2Session1, children: [], worktree: null },
{ session: project2Session2, children: [], worktree: null },
],
},
],
},
];
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('useProjectSessionSelection — worktree session click race', () => {
test('stale projectSections (no worktree group) excludes worktree sessions from projectMap', () => {
const { metaByProject } = computeProjectMeta(staleSections);
const projectMap = metaByProject.get('project-1');
// Root sessions are present
expect(projectMap?.has('root-session-1')).toBe(true);
expect(projectMap?.has('root-session-2')).toBe(true);
// Worktree session is NOT present — this is what triggers the bug
expect(projectMap?.has('wt-session-1')).toBe(false);
});
test('stale data firstSessionByProject points to first root session, not worktree session', () => {
const { firstSessionByProject } = computeProjectMeta(staleSections);
// Path C would fall back to firstSessionByProject, which is the first ROOT session
const first = firstSessionByProject.get('project-1');
expect(first?.id).toBe('root-session-1');
expect(first?.id).not.toBe('wt-session-1');
});
test('updated projectSections includes all sessions including worktree', () => {
const { metaByProject } = computeProjectMeta(updatedSections);
const projectMap = metaByProject.get('project-1');
expect(projectMap?.has('root-session-1')).toBe(true);
expect(projectMap?.has('root-session-2')).toBe(true);
expect(projectMap?.has('wt-session-1')).toBe(true);
});
test('second click works correctly when projectSections is updated', () => {
const { metaByProject } = computeProjectMeta(updatedSections);
const projectMap = metaByProject.get('project-1')!;
const currentSessionId = 'wt-session-1';
// After data arrives, Path A succeeds — no guard needed
const pathAHit = Boolean(currentSessionId && projectMap?.has(currentSessionId));
expect(pathAHit).toBe(true);
});
test('project switch: Path A succeeds when currentSessionId matches the new project', () => {
const { metaByProject } = computeProjectMeta(project2Sections);
const projectMap = metaByProject.get('project-2')!;
const currentSessionId = 'project-2-session-1';
const pathAHit = Boolean(currentSessionId && projectMap?.has(currentSessionId));
expect(pathAHit).toBe(true);
});
});
describe('resolveMissingProjectSessionSelection', () => {
test('A → B selects B remembered session when the current session is owned by A', () => {
const projectBMap = new Map([
['project-b-first-session', null],
['project-b-remembered-session', null],
]);
expect(resolveMissingProjectSessionSelection({
activeProjectId: 'project-b',
currentSessionId: 'stale-worktree-session-a',
currentSessionOwnerProjectId: 'project-a',
projectMap: projectBMap,
metaByProject: new Map([['project-b', projectBMap]]),
rememberedSessionId: 'project-b-remembered-session',
fallbackSessionId: 'project-b-first-session',
})).toEqual({ kind: 'select-session', sessionId: 'project-b-remembered-session' });
});
test('A → B falls back to B first session when none is remembered', () => {
const projectAMap = new Map([['project-a-session', null]]);
const projectBMap = new Map([['project-b-first-session', null]]);
const metaByProject = new Map([
['project-a', projectAMap],
['project-b', projectBMap],
]);
expect(resolveMissingProjectSessionSelection({
activeProjectId: 'project-b',
currentSessionId: 'project-a-session',
currentSessionOwnerProjectId: 'project-a',
projectMap: projectBMap,
metaByProject,
rememberedSessionId: undefined,
fallbackSessionId: 'project-b-first-session',
})).toEqual({ kind: 'select-session', sessionId: 'project-b-first-session' });
});
test('A → B opens a B-scoped draft when B is empty', () => {
expect(resolveMissingProjectSessionSelection({
activeProjectId: 'project-b',
currentSessionId: 'project-a-session',
currentSessionOwnerProjectId: 'project-a',
projectMap: undefined,
metaByProject: new Map([['project-a', new Map([['project-a-session', null]])]]),
rememberedSessionId: undefined,
fallbackSessionId: null,
})).toEqual({ kind: 'open-draft' });
});
test('preserves a same-project worktree session missing from a stale projectMap', () => {
const projectMap = new Map([['root-session-1', null]]);
const metaByProject = new Map([['project-1', projectMap]]);
expect(resolveMissingProjectSessionSelection({
activeProjectId: 'project-1',
currentSessionId: 'wt-session-1',
currentSessionOwnerProjectId: 'project-1',
projectMap,
metaByProject,
rememberedSessionId: undefined,
fallbackSessionId: 'root-session-1',
})).toEqual({ kind: 'preserve-current' });
});
test('preserves an unknown session while worktree metadata may still be loading', () => {
const projectMap = new Map([['root-session-1', null]]);
const metaByProject = new Map([['project-1', projectMap]]);
expect(resolveMissingProjectSessionSelection({
activeProjectId: 'project-1',
currentSessionId: 'wt-session-1',
currentSessionOwnerProjectId: null,
projectMap,
metaByProject,
rememberedSessionId: undefined,
fallbackSessionId: 'root-session-1',
})).toEqual({ kind: 'preserve-current' });
});
test('unknown ownership still switches when the session already appears under another project', () => {
const projectAMap = new Map([['project-a-session', null]]);
const projectBMap = new Map([
['project-b-first-session', null],
['project-b-remembered-session', null],
]);
const metaByProject = new Map([
['project-a', projectAMap],
['project-b', projectBMap],
]);
expect(resolveMissingProjectSessionSelection({
activeProjectId: 'project-b',
currentSessionId: 'project-a-session',
currentSessionOwnerProjectId: null,
projectMap: projectBMap,
metaByProject,
rememberedSessionId: 'project-b-remembered-session',
fallbackSessionId: 'project-b-first-session',
})).toEqual({ kind: 'select-session', sessionId: 'project-b-remembered-session' });
});
test('deleted or missing currentSessionId falls through to remembered/fallback selection', () => {
const projectMap = new Map([['root-session-1', null]]);
expect(resolveMissingProjectSessionSelection({
activeProjectId: 'project-1',
currentSessionId: null,
currentSessionOwnerProjectId: null,
projectMap,
metaByProject: new Map([['project-1', projectMap]]),
rememberedSessionId: undefined,
fallbackSessionId: 'root-session-1',
})).toEqual({ kind: 'select-session', sessionId: 'root-session-1' });
});
test('empty projects resolve to opening a draft', () => {
expect(resolveMissingProjectSessionSelection({
activeProjectId: 'empty-project',
currentSessionId: 'some-session-id',
currentSessionOwnerProjectId: null,
projectMap: undefined,
metaByProject: new Map<string, Map<string, null>>(),
rememberedSessionId: undefined,
fallbackSessionId: null,
})).toEqual({ kind: 'open-draft' });
});
});
// ---------------------------------------------------------------------------
// Hook-level: ProjectSessionSelectionEffect recovery / preserve
// ---------------------------------------------------------------------------
const installMinimalDom = () => {
const descriptors = new Map<string, PropertyDescriptor | undefined>();
const setGlobal = (name: string, value: unknown) => {
descriptors.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
Object.defineProperty(globalThis, name, { configurable: true, writable: true, value });
};
class ElementStub {}
const documentStub: Record<string, unknown> = {
nodeType: 9,
defaultView: globalThis,
activeElement: null,
addEventListener: () => undefined,
removeEventListener: () => undefined,
};
const container = {
nodeType: 1,
tagName: 'DIV',
nodeName: 'DIV',
namespaceURI: 'http://www.w3.org/1999/xhtml',
ownerDocument: documentStub,
addEventListener: () => undefined,
removeEventListener: () => undefined,
};
documentStub.documentElement = container;
documentStub.body = container;
setGlobal('document', documentStub);
setGlobal('window', globalThis);
setGlobal('location', { search: '', protocol: 'http:', hostname: 'localhost' });
setGlobal('Element', ElementStub);
setGlobal('HTMLElement', ElementStub);
setGlobal('HTMLIFrameElement', ElementStub);
setGlobal('IS_REACT_ACT_ENVIRONMENT', true);
setGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => setTimeout(() => callback(Date.now()), 0));
setGlobal('cancelAnimationFrame', (id: ReturnType<typeof setTimeout>) => clearTimeout(id));
return {
container: container as unknown as Element,
restore: () => {
for (const [name, descriptor] of descriptors) {
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
else Reflect.deleteProperty(globalThis, name);
}
},
};
};
type SelectionEffectProps = React.ComponentProps<typeof ProjectSessionSelectionEffect>;
const bothProjectSections: ProjectSection[] = [staleSections[0]!, project2Sections[0]!];
function mountSelectionEffect(initial: {
activeProjectId: string;
projectSections: ProjectSection[];
sessionId: string | null;
sessionOwnerBySessionId?: ReadonlyMap<string, { projectId: string }>;
rememberedByProject?: Map<string, string>;
}) {
currentSessionId = initial.sessionId;
newSessionDraftOpen = false;
isNewWorktreeDialogOpen = false;
const sessionSelectCalls: Array<[string, string | null]> = [];
const draftCalls: Array<{ selectedProjectId?: string | null; directoryOverride?: string | null } | undefined> = [];
const dom = installMinimalDom();
const root: Root = createRoot(dom.container);
const props: SelectionEffectProps = {
projectSections: initial.projectSections,
activeProjectId: initial.activeProjectId,
initialActiveSessionByProject: initial.rememberedByProject ?? new Map(),
persistActiveSessionByProject: () => undefined,
handleSessionSelect: (sessionId, sessionDirectory) => {
sessionSelectCalls.push([sessionId, sessionDirectory]);
},
mobileVariant: false,
openNewSessionDraft: (options) => {
draftCalls.push(options);
},
setActiveMainTab: () => undefined,
setSessionSwitcherOpen: () => undefined,
sessionOwnerBySessionId: initial.sessionOwnerBySessionId,
};
act(() => {
root.render(React.createElement(ProjectSessionSelectionEffect, props));
});
return {
sessionSelectCalls,
draftCalls,
rerender: (next: Partial<SelectionEffectProps> & { sessionId?: string | null }) => {
const { sessionId, ...effectProps } = next;
if (sessionId !== undefined) currentSessionId = sessionId;
Object.assign(props, effectProps);
act(() => {
root.render(React.createElement(ProjectSessionSelectionEffect, props));
});
},
teardown: () => {
act(() => {
root.unmount();
});
dom.restore();
},
};
}
describe('ProjectSessionSelectionEffect — ownership recovery', () => {
let teardown: (() => void) | null = null;
afterEach(() => {
teardown?.();
teardown = null;
currentSessionId = null;
newSessionDraftOpen = false;
isNewWorktreeDialogOpen = false;
});
test('A → B with later foreign ownership selects B remembered session', () => {
const missingASessionId = 'session-a-missing-from-maps';
const mounted = mountSelectionEffect({
activeProjectId: 'project-1',
projectSections: bothProjectSections,
sessionId: missingASessionId,
sessionOwnerBySessionId: new Map([[missingASessionId, { projectId: 'project-1' }]]),
rememberedByProject: new Map([['project-2', 'project-2-session-2']]),
});
teardown = mounted.teardown;
expect(mounted.sessionSelectCalls).toEqual([]);
mounted.rerender({
activeProjectId: 'project-2',
sessionOwnerBySessionId: new Map(),
});
expect(mounted.sessionSelectCalls).toEqual([]);
mounted.rerender({
sessionOwnerBySessionId: new Map([[missingASessionId, { projectId: 'project-1' }]]),
});
expect(mounted.sessionSelectCalls).toEqual([
['project-2-session-2', '/workspace/project-2'],
]);
});
test('A → B with known foreign ownership selects B remembered session', () => {
const mounted = mountSelectionEffect({
activeProjectId: 'project-1',
projectSections: bothProjectSections,
sessionId: 'root-session-1',
sessionOwnerBySessionId: new Map([['root-session-1', { projectId: 'project-1' }]]),
rememberedByProject: new Map([['project-2', 'project-2-session-2']]),
});
teardown = mounted.teardown;
expect(mounted.sessionSelectCalls).toEqual([]);
mounted.rerender({ activeProjectId: 'project-2' });
expect(mounted.sessionSelectCalls).toEqual([
['project-2-session-2', '/workspace/project-2'],
]);
});
test('stale same-project worktree selection stays put when ownership arrives', () => {
const mounted = mountSelectionEffect({
activeProjectId: 'project-1',
projectSections: staleSections,
sessionId: 'wt-session-1',
sessionOwnerBySessionId: new Map(),
rememberedByProject: new Map([['project-1', 'root-session-1']]),
});
teardown = mounted.teardown;
expect(mounted.sessionSelectCalls).toEqual([]);
expect(mounted.draftCalls).toEqual([]);
mounted.rerender({
sessionOwnerBySessionId: new Map([['wt-session-1', { projectId: 'project-1' }]]),
});
expect(mounted.sessionSelectCalls).toEqual([]);
expect(mounted.draftCalls).toEqual([]);
});
});
@@ -254,7 +254,6 @@ export const useProjectSessionSelection = (args: Args): void => {
return next;
});
}, [activeProjectId, currentSessionId, projectSessionMeta, setActiveSessionByProject]);
};
type ProjectSessionSelectionEffectProps = Omit<
@@ -28,7 +28,7 @@ const sessionDirectory = (session: Session | null | undefined): string | null =>
return typeof directory === 'string' && directory.trim() ? directory : null;
};
export const useSessionPrefetch = ({ enabled = true, currentSessionId, sortedSessions, recentSessions = [], prefetchSession }: Args): void => {
const useSessionPrefetch = ({ enabled = true, currentSessionId, sortedSessions, recentSessions = [], prefetchSession }: Args): void => {
const sessionPrefetchTimersRef = React.useRef<Map<string, number>>(new Map());
const sessionPrefetchQueueRef = React.useRef<PrefetchRequest[]>([]);
const sessionPrefetchInFlightRef = React.useRef<Set<string>>(new Set());
@@ -1,4 +1,4 @@
import { cva, type VariantProps } from 'class-variance-authority';
import { cva } from 'class-variance-authority';
/**
* Single source of truth for every dropdown-style trigger surface in the app:
@@ -34,6 +34,3 @@ export const dropdownTriggerVariants = cva(
},
},
);
export type DropdownTriggerVariantProps = VariantProps<typeof dropdownTriggerVariants>;
export type DropdownTriggerSize = NonNullable<DropdownTriggerVariantProps['size']>;
@@ -52,7 +52,7 @@ interface PierreDiffViewerProps {
* and enables touch-friendly line interactions. Re-exported so plain
* <PierreFile> consumers (e.g. `MobileFilesSurface`) can inject the same.
*/
export const PIERRE_RUNTIME_BASE_CSS = `
const PIERRE_RUNTIME_BASE_CSS = `
:host {
font-family: var(--font-mono);
font-size: var(--text-code);
+1 -1
View File
@@ -41,7 +41,7 @@ const MAX_CHUNK_CHARS = 400;
* Sentences are merged until MIN_CHUNK_CHARS and hard-split at
* MAX_CHUNK_CHARS so a single run-on sentence cannot stall the pipeline.
*/
export function splitTextForSynthesis(text: string): string[] {
function splitTextForSynthesis(text: string): string[] {
const normalized = text.replace(/\s+/g, ' ').trim();
if (!normalized) {
return [];
+1 -1
View File
@@ -5,7 +5,7 @@ import { useUIStore } from '@/stores/useUIStore';
// How long the chat must sit untouched before the recap becomes visible.
// The suggestion has no such delay — it shows as soon as it arrives.
export const RECAP_VISIBILITY_DELAY_MS = 60 * 1000;
const RECAP_VISIBILITY_DELAY_MS = 60 * 1000;
interface LastMessageSnapshot {
id: string;
+1 -1
View File
@@ -253,7 +253,7 @@ const getDesktopBridge = (): DesktopBridgeGlobal | null => {
export const isElectronShell = (): boolean => getElectronRuntime()?.runtime === 'electron';
export const getElectronPlatform = (): string | null => {
const getElectronPlatform = (): string | null => {
if (typeof window === 'undefined') return null;
const platform = (window as unknown as { __OPENCHAMBER_PLATFORM__?: string }).__OPENCHAMBER_PLATFORM__;
return typeof platform === 'string' ? platform : null;
+1 -1
View File
@@ -28,7 +28,7 @@ let candidateRefreshInFlight = false;
* (stable tunnel hostname) is never overwritten: the DHCP problem does not apply
* to it and the server does not know its own public hostnames.
*/
export const refreshDesktopHostCandidates = async (hostId: string): Promise<void> => {
const refreshDesktopHostCandidates = async (hostId: string): Promise<void> => {
if (!isElectronShell() || candidateRefreshInFlight) return;
const runtimeKey = `host:${hostId}`;
// The candidates fetch rides the active runtime's transport — only meaningful
-25
View File
@@ -9,32 +9,7 @@ import { useConfigStore } from '@/stores/useConfigStore';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
export type {
GitStatus,
GitDiffResponse,
GetGitDiffOptions,
GitBranchDetails,
GitBranch,
GitCommitResult,
GitPushResult,
GitPullResult,
GitIdentityProfile,
GitIdentityAuthType,
GitIdentitySummary,
GitLogEntry,
GitLogResponse,
GitWorktreeInfo,
CreateGitWorktreePayload,
GitWorktreeCreateResult,
RemoveGitWorktreePayload,
GitWorktreeValidationError,
GitWorktreeValidationResult,
GitDeleteBranchPayload,
GitDeleteRemoteBranchPayload,
GitRemoveRemotePayload,
DiscoveredGitCredential,
GitRemote,
GitMergeResult,
GitRebaseResult,
MergeConflictDetails,
CommitFileDiffResponse,
} from './api/types';
+2 -2
View File
@@ -139,9 +139,9 @@ export const resetHardwareKeyboardDetection = (): void => {
setHardwareKeyboardAttached(false);
};
export const isHardwareKeyboardAttached = (): boolean => hardwareKeyboardAttached;
const isHardwareKeyboardAttached = (): boolean => hardwareKeyboardAttached;
export const subscribeHardwareKeyboard = (listener: () => void): (() => void) => {
const subscribeHardwareKeyboard = (listener: () => void): (() => void) => {
subscribers.add(listener);
return () => {
subscribers.delete(listener);
-2
View File
@@ -3036,5 +3036,3 @@ export const dict = {
'settings.mcp.page.connection.hintCommand': 'Sexécute sur cette machine. Collez une commande entière : elle est découpée en un argument par ligne.',
'settings.mcp.page.connection.hintLink': 'Se connecte à un serveur hébergé par quelquun dautre. Collez son adresse https.',
} as const;
export type I18nKey = keyof typeof dict;
@@ -1,8 +1,7 @@
/**
* Provider Circuit-Breaker & Retry Tracker
* Provider Circuit-Breaker Tracker
*
* Tracks per-provider error state to enable:
* - Transparent retry with exponential backoff for transient errors
* - Circuit breaking (pause requests to a provider during error storms)
*
* Inspired by HiveMind (arXiv:2604.17111) OS-inspired scheduling primitives.
@@ -12,9 +11,7 @@ import { getRuntimeKey } from '@/lib/runtime-switch'
const DEFAULT_CIRCUIT_BREAK_THRESHOLD = 3
const DEFAULT_CIRCUIT_COOLDOWN_MS = 30_000
const DEFAULT_RETRY_BASE_DELAY_MS = 1000
const DEFAULT_RETRY_MAX_DELAY_MS = 32_000
const DEFAULT_RETRY_MAX_ATTEMPTS = 3
const PROVIDER_EVICTION_TTL_MS = 60 * 60 * 1000
const PROVIDER_EVICTION_INTERVAL_MS = 10 * 60 * 1000
const PROVIDER_MAX_ENTRIES = 200
@@ -113,19 +110,7 @@ function isCircuitOpen(providerID: string): boolean {
return true
}
export function shouldRetry(providerID: string, status: number, attempt: number): boolean {
if (!RETRYABLE_STATUS_CODES.has(status)) return false
if (attempt >= DEFAULT_RETRY_MAX_ATTEMPTS - 1) return false
if (isCircuitOpen(providerID)) return false
return true
}
export function assertProviderCircuitClosed(providerID: string): void {
if (!providerID || !isCircuitOpen(providerID)) return
throw new Error(`Provider ${providerID} is temporarily unavailable after repeated errors. Please retry shortly.`)
}
export function getRetryDelayMs(attempt: number): number {
const delay = DEFAULT_RETRY_BASE_DELAY_MS * 2 ** attempt
return Math.min(delay, DEFAULT_RETRY_MAX_DELAY_MS)
}
-11
View File
@@ -83,10 +83,6 @@ export interface TunnelWsOpenPayload {
protocols?: string[];
}
export interface TunnelWsOpenedPayload {
protocol?: string;
}
export interface TunnelWsClosePayload {
code: number;
reason: string;
@@ -111,13 +107,6 @@ export interface E2eeReadyMessage {
batch?: boolean;
}
// Layer 1 control messages (relay <-> host control socket).
export type RelayControlMessage =
| { type: 'sync'; connectionIds: string[] }
| { type: 'connected'; connectionId: string }
| { type: 'disconnected'; connectionId: string }
| { type: 'limit'; reason: string };
// Relay-assigned WebSocket close codes.
export const RelayCloseCode = {
ControlReplaced: 4001,
-3
View File
@@ -3,12 +3,9 @@ import { configureRuntimeUrlResolver } from '@/lib/runtime-url';
import {
activateRelayTunnel,
deactivateRelayTunnel,
getActiveRelayTunnel,
type RelayRuntimeDescriptor,
} from '@/lib/relay/runtime-tunnel';
export { getActiveRelayTunnel };
export type RuntimeEndpointChangedDetail = {
apiBaseUrl: string;
previousApiBaseUrl: string;
+1 -1
View File
@@ -29,7 +29,7 @@ const isTouchOrCoarsePointer = (): boolean => {
* shell (always the mobile surface) desktop shells phone heuristic
* gated by the stored mobile layout preference.
*/
export const detectHostedSurface = (): HostedSurface => {
const detectHostedSurface = (): HostedSurface => {
if (typeof window === 'undefined') return 'desktop';
const explicitSurface = window.__OPENCHAMBER_SURFACE__;
-1
View File
@@ -189,7 +189,6 @@ Good:
- `useGitBranches(directory)`
- `useGitBranchLabel(directory)`
- `useGitRepoStatusMap(directories)`
- `usePrVisualSummaryByKeys(keys)`
Bad:
@@ -932,11 +932,8 @@ const deriveSummary = (entry: PrStatusEntry): PrVisualSummary | null => {
const summarySignature = (s: PrVisualSummary): string =>
`${s.number}:${s.visualState}:${s.prState}:${s.draft}:${s.title ?? ''}:${s.url ?? ''}:${s.base ?? ''}:${s.head ?? ''}:${s.canMerge ?? ''}:${s.mergeableState ?? ''}:${s.checks?.state ?? ''}:${s.checks?.total ?? ''}:${s.checks?.success ?? ''}:${s.checks?.failure ?? ''}:${s.checks?.pending ?? ''}:${s.repo?.owner ?? ''}:${s.repo?.repo ?? ''}`;
let prKeyedCacheSigs = new Map<string, string>();
let prKeyedCacheResult: Map<string, PrVisualSummary> = new Map();
// Per-key summary cache so many independent row subscribers (one key each)
// keep referential stability without fighting over the multi-key cache above.
// keep referential stability.
// Practically bounded by the number of worktree branches observed in a
// session; the explicit cap below guards long-running documents that rotate
// through many branches/runtimes (entries are tiny; insertion-order eviction
@@ -964,34 +961,3 @@ export const usePrVisualSummary = (key: string | null): PrVisualSummary | null =
return summary;
});
};
export const usePrVisualSummaryByKeys = (keys: string[]) => {
return useGitHubPrStatusStore((state) => {
// Derive summaries for requested keys only
const nextSigs = new Map<string, string>();
const nextSummaries = new Map<string, PrVisualSummary>();
for (const key of keys) {
const entry = state.entries[key];
if (!entry) continue;
const summary = deriveSummary(entry);
if (!summary) continue;
const sig = summarySignature(summary);
nextSigs.set(key, sig);
nextSummaries.set(key, summary);
}
// Compare with cached signatures
if (nextSigs.size === prKeyedCacheSigs.size) {
let same = true;
for (const [k, sig] of nextSigs) {
if (prKeyedCacheSigs.get(k) !== sig) { same = false; break; }
}
if (same) return prKeyedCacheResult;
}
prKeyedCacheSigs = nextSigs;
prKeyedCacheResult = nextSummaries;
return nextSummaries;
});
};
@@ -77,4 +77,4 @@ export const useSessionDisplayStore = create<SessionDisplayStore>()(
),
);
export type { ProjectSortOrder, SessionGroupingMode };
export type { ProjectSortOrder };
@@ -1,5 +1,15 @@
import { afterEach, describe, expect, it } from 'bun:test';
import { createEventPipeline } from '../event-pipeline';
import { afterEach, describe, expect, it, mock } from 'bun:test';
// A WebSocket attempt mints an `oc_url_token` before connecting, because a WS
// upgrade cannot carry an Authorization header. Stub only that mint so the
// socket assertions below exercise the transport rather than the auth round-trip.
const actualRuntimeAuth = await import('@/lib/runtime-auth');
mock.module('@/lib/runtime-auth', () => ({
...actualRuntimeAuth,
refreshRuntimeUrlAuthToken: async () => 'test-url-token',
}));
const { createEventPipeline } = await import('../event-pipeline');
const originalDocument = globalThis.document;
const originalWindow = globalThis.window;
@@ -48,9 +58,9 @@ class FakeWebSocket {
this.onmessage?.({ data: JSON.stringify(payload) });
}
emitClose() {
emitClose(code = 1006, reason = '') {
this.readyState = 3;
this.onclose?.();
this.onclose?.({ code, reason });
}
}
@@ -7,7 +7,6 @@ import {
findLiveSession,
findLiveSessionStatus,
} from '../live-aggregate.ts'
import { deriveRecentSessions, RECENT_SESSION_MAX_AGE_MS } from '../../components/session/sidebar/activitySections.ts'
const session = (id, directory, updated, extra = {}) => ({
id,
@@ -94,19 +93,4 @@ describe('live aggregate', () => {
)).toBe(false)
})
it('derives recent sessions from the 48h window, excluding archived/subtasks', () => {
const now = 1_000_000_000
const sessions = [
session('ses-1', '/a', now - 1_000),
session('ses-2', '/b', now - 500),
session('ses-3', '/c', now - 10, { time: { created: now - 11, updated: now - 10, archived: now - 5 } }),
session('ses-4', '/d', now - 200, { parentID: 'ses-parent' }),
session('ses-5', '/e', now - RECENT_SESSION_MAX_AGE_MS - 1),
]
const recent = deriveRecentSessions(sessions, now)
// ses-3 archived, ses-4 subtask, ses-5 older than 48h -> excluded; rest newest-first
expect(recent.map((item) => item.id)).toEqual(['ses-2', 'ses-1'])
})
})
+1 -1
View File
@@ -236,7 +236,7 @@ function updateLiveSession(session: Session, directory?: string): boolean {
return false
}
export function mirrorSessionIntoLiveStores(session: Session, directory?: string): void {
function mirrorSessionIntoLiveStores(session: Session, directory?: string): void {
if (directory && updateLiveSession(session, directory)) {
return
}
-3
View File
@@ -255,9 +255,6 @@ function notifyMessageSent(sessionId: string): void {
// Types
// ---------------------------------------------------------------------------
export type { SyntheticContextPart } from "./input-store"
export type { SessionMemoryState } from "./viewport-store"
export type NewSessionDraftState = {
open: boolean
selectedProjectId?: string | null
@@ -6,30 +6,9 @@ import {
formatSessionWorktreeBadge,
getSessionWorktreeRepairActions,
getMutationBlockingReasons,
isWithinWorktreeRoot,
buildSessionTargetOptions,
} from './session-worktree-contract';
describe('isWithinWorktreeRoot', () => {
test('returns true when candidate equals root', () => {
expect(isWithinWorktreeRoot('/repo/worktrees/feat-a', '/repo/worktrees/feat-a')).toBe(true);
});
test('returns true when candidate is a subdirectory of root', () => {
expect(isWithinWorktreeRoot('/repo/worktrees/feat-a/src', '/repo/worktrees/feat-a')).toBe(true);
});
test('returns false when candidate is outside root', () => {
expect(isWithinWorktreeRoot('/tmp/outside', '/repo/worktrees/feat-a')).toBe(false);
});
test('returns false when either is null/empty', () => {
expect(isWithinWorktreeRoot(null, '/repo')).toBe(false);
expect(isWithinWorktreeRoot('/repo', null)).toBe(false);
expect(isWithinWorktreeRoot('', '/repo')).toBe(false);
});
});
describe('getAttachedSessionDirectory', () => {
test('prefers canonical cwd when attachment is healthy', () => {
expect(getAttachedSessionDirectory({
-33
View File
@@ -2769,26 +2769,6 @@ export function useChildStoreManager() {
return useSyncSystem().childStores
}
export type SessionTextMessage = {
id: string
role: string | null
text: string
}
const getPartText = (part: Part): string => {
if (part?.type !== "text") return ""
const text = (part as { text?: unknown }).text
return typeof text === "string" ? text : ""
}
const getConcatenatedTextFromParts = (parts: Part[]): string => {
let text = ""
for (const part of parts) {
text += getPartText(part)
}
return text
}
type SessionMessageRecord = { info: Message; parts: Part[] }
const EMPTY_SESSION_MESSAGE_RECORDS: SessionMessageRecord[] = []
@@ -3114,19 +3094,6 @@ export function useSessionRenderable(sessionID: string, directory?: string): boo
return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
}
export function useSessionTextMessages(sessionID: string, directory?: string): SessionTextMessage[] {
const records = useSessionMessageRecords(sessionID, directory)
return useMemo(
() => records.map((record) => ({
id: record.info.id,
role: typeof record.info.role === "string" ? record.info.role : null,
text: getConcatenatedTextFromParts(record.parts),
})),
[records],
)
}
export function useUserMessageHistory(sessionID: string, directory?: string): string[] {
const store = useDirectoryStore(directory)
const snapshotRef = useRef<UserMessageHistorySnapshot>(EMPTY_USER_MESSAGE_HISTORY_SNAPSHOT)