feat(web-shell): improve sidebar session management (#9311)

* feat(web-shell): improve sidebar session management

* feat(web-shell): keep sessions accessible when sidebar is collapsed

* test(web-shell): cover hover session details

* test(web-shell): align workspace sidebar coverage

* fix(web-shell): keep collapsed session actions open

* fix(web-shell): layer collapsed session menus

* fix(web-shell): address sidebar session details review findings (#9122)

- sdk: route workspace session metadata PATCH through direct REST
- collapsed switcher: cancel stale hover-close timers on reopen, keep the
  surface open while the group picker or keyboard focus is inside it, and
  emit the missing close signal when a tracked menu unmounts open
- suppress the session menu's close focus restore when it started a rename
- migrate the primary workspace expansion preference across the
  provisional-to-resolved cwd key change
- honor a persisted workspace collapse over stale one-shot auto-expansion
- drop the inert archived-row tab stop
- align the constrained smoke test with the single-line details title

* fix(web-shell): resolve standing sidebar session details blockers (#9122)

- collapsed switcher: resolve pointer targets through composedPath and
  make the close timer's focus guard shadow-DOM aware so hover-open
  containment works in shadowDom portal mode
- reset search state when the sidebar collapses so the autofocused
  search input no longer mounts inside the hover popover and steals
  keyboard focus
- rename: propagate the daemon-resolved displayName (clamped to 256)
  instead of the locally typed string and cap both rename inputs at
  256 characters
- keep the session list scrollable clear of the fixed footer so rows
  stay hoverable, and close the details popover before each
  constrained re-hover in the smoke test
- projects section: write the expansion preference outside the state
  updater, never lock hideProjectHeader consumers behind a stored
  collapse, and reset the one-shot show-all per session source and
  primary workspace
- stop a double-click inside a mounted rename input from restarting
  the rename and discarding the typed text

* fix: address round-6 review findings in serve metadata and sidebar (#9122)

- serve: reject empty/whitespace displayName on the workspace metadata
  route so archived sessions never persist an empty custom_title record
- serve: advertise workspace_session_metadata in the integration
  capability baseline to match the registry and unit baselines
- sidebar: end the session scroll port above the fixed footer so rows
  can never park under it and block hover (drops stale clearances)
- sidebar: reset search state whenever the collapsed surface closes so
  a stale autofocused input cannot steal composer focus on hover-open
- sidebar: keep keyboard-opened collapsed switcher in keyboard
  semantics; a pointer graze no longer suppresses focus restoration
- sidebar: busy-guard the archived rename menu item, align the
  group-create icon with its siblings, and reset per-section show-all
  on session-source change to match the flat list

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix: address round-7 review findings in serve metadata and sidebar (#9122)

* fix: address round-8 review findings in sidebar rename and switcher (#9122)

* fix: address round-9 review findings in sidebar rename and menus (#9122)

* fix: address round-10 review findings in sidebar actions and rename (#9122)

* fix(web-shell): remove unsafe rename unmount cleanup

* fix(web-shell): stabilize sidebar session mutations

* fix(web-shell): polish sidebar session interactions

* feat(web-shell): complete collapsed sidebar navigation

---------

Co-authored-by: 钉萁 <dingqi.jww@alibaba-inc.com>
Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
ytahdn 2026-08-17 06:06:56 +00:00 committed by GitHub
parent ba2d512497
commit 9a5b07c0b1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 4774 additions and 1922 deletions

View file

@ -0,0 +1,41 @@
# Web Shell sidebar session details
## Goal
Make session rows easier to scan without adding another navigation surface:
- show the existing details panel from row hover and remove the Details action
from the overflow menu;
- preview five sessions per expanded folder or session group, with an explicit
control to reveal the remainder until that section is collapsed;
- move timestamps into the details panel and reserve the row's trailing slot
for branch or worktree state;
- fade overflowing titles at the right edge and scroll them slowly on hover;
- use a neutral spinner for running sessions;
- keep the brand, New task action, and footer fixed while the remaining
navigation and session content share one scroll area.
## Design
The row remains the only session-selection and keyboard target. A controlled
Radix popover is anchored to it and opens only from pointer hover. The panel
does not participate in keyboard navigation; its session ID copy action is a
pointer-only affordance. The panel contains the title and relative time, final
workspace path segment, optional git branch or worktree, session status, and a
copyable session ID. Existing action menus keep all mutation actions but no
longer include Details. Rename targets the selected session through its owning
workspace, so current, background, secondary-workspace, and archived sessions
share the same action.
Session limits are local UI state. Direct workspace lists and grouped lists
show the first five items; revealing the remainder is not persisted, so
collapsing and reopening the owning section restores the five-item preview.
Title overflow uses a CSS mask for the trailing fade. On hover, one DOM width
measurement supplies the exact scroll distance to a CSS animation, avoiding a
timer or dependency.
The workspace-qualified metadata route keeps background-session renames inside
the resolved workspace runtime. Its dedicated `workspace_session_metadata`
capability prevents clients from exposing the action against older daemons
that do not mount the route. No session schema changes are required.

View file

@ -0,0 +1,25 @@
# Web Shell collapsed session switcher
## Goal
Keep session switching available while the sidebar is collapsed without adding
another navigation model.
## Design
The collapsed sidebar shows one Project icon in the scrolling navigation area.
Pointer hover or click opens a Popover containing the same complete session
browser used by the expanded sidebar. Source tabs, pinned and live sessions,
project search, workspace actions, grouping, preview limits, archived sessions,
and expansion preferences therefore follow one implementation in both states.
Selecting or managing a session keeps the Popover open so several operations
can be performed in sequence. Pointer-opened content closes after the pointer
leaves, while outside clicks and Escape keep their normal dismissal behavior.
Focus moved to the composer after a session switch must not dismiss the
Popover, and a workspace prop catching up with an already loaded session must
not trigger a second load.
The Project icon shows the same pulsing status color used by expanded session
rows when any visible workspace has a completed session or needs approval or
an answer. Approval takes precedence over questions, which take precedence
over completion.

View file

@ -406,6 +406,7 @@ describe('qwen serve — capabilities envelope', () => {
'workspace_persisted_transcript',
'workspace_session_export',
'workspace_archived_session_export',
'workspace_session_metadata',
'voice_transcribe',
]);
});

View file

@ -385,6 +385,9 @@ export const SERVE_CAPABILITY_REGISTRY = {
// This remains independent from active export so older daemons cannot ignore
// archive intent and return an active transcript with the same session id.
workspace_archived_session_export: { since: 'v1' },
// Workspace-qualified metadata updates for active, inactive, and archived
// persisted sessions.
workspace_session_metadata: { since: 'v1' },
// Workspace-qualified ACP transport (issue #6378 Phase 4):
// `/workspaces/:workspace/acp` mounts a per-runtime ACP dispatcher (HTTP +
// WebSocket) for each registered workspace, with per-runtime device-flow and

View file

@ -51,6 +51,7 @@ import { parseChannelDelivery } from '../../runtime/channel-delivery.js';
import {
canonicalizeWorkspace,
InvalidClientIdError,
InvalidSessionMetadataError,
PromptQueueFullError,
SessionArtifactValidationError,
SessionArchivedError,
@ -4844,6 +4845,124 @@ export function registerSessionRoutes(
),
);
app.patch(
'/workspaces/:workspace/session/:id/metadata',
mutate({ strict: true }),
async (req, res) => {
const route = 'PATCH /workspaces/:workspace/session/:id/metadata';
const runtime = requireTrustedRuntimeForWorkspaceRoute(req, res, route);
if (!runtime) return;
const sessionId = requireSessionId(req, res);
if (sessionId === null) return;
const clientId = parseClientIdHeader(req, res);
if (clientId === null) return;
const rawDisplayName = safeBody(req)['displayName'];
if (typeof rawDisplayName !== 'string') {
res.status(400).json({
error: '`displayName` must be a string',
code: 'invalid_metadata',
field: 'displayName',
});
return;
}
try {
const displayName = rawDisplayName.slice(0, 256);
if (displayName.trim() === '') {
// An empty name would append an empty custom_title record to
// persisted sessions, which the title readers disagree on.
throw new InvalidSessionMetadataError(
'displayName',
'must not be empty',
);
}
if (
Array.from(displayName).some((character) => {
const code = character.charCodeAt(0);
return code <= 31 || code === 127;
})
) {
throw new InvalidSessionMetadataError(
'displayName',
'must not contain control characters',
);
}
await archiveCoordinator.runExclusiveMany([sessionId], async () => {
const assertRuntimeGenerationOpen =
captureRuntimeGenerationAssertion(runtime);
assertRuntimeGenerationOpen?.();
const liveOwner =
workspaceRegistry.resolveLiveSessionOwner(sessionId);
if (liveOwner.kind === 'unavailable') {
sendWorkspaceRuntimeUnavailable(res);
return;
}
if (liveOwner.kind === 'ambiguous') {
sendAmbiguousSessionOwner(
res,
route,
sessionId,
liveOwner.runtimes,
);
return;
}
if (
liveOwner.kind === 'found' &&
liveOwner.runtime.workspaceCwd !== runtime.workspaceCwd
) {
sendSessionWorkspaceConflict(
res,
route,
sessionId,
runtime,
liveOwner.runtime,
);
return;
}
await runWithWorkspaceRuntimeStorage(runtime, async () => {
let effective: { displayName?: string };
try {
effective = runtime.bridge.updateSessionMetadata(
sessionId,
{ displayName },
clientId !== undefined ? { clientId } : undefined,
);
assertRuntimeGenerationOpen?.();
} catch (err) {
if (!(err instanceof SessionNotFoundError)) throw err;
const service = createWorkspaceRuntimeSessionService(runtime);
const location = await service.getSessionLocation(sessionId);
assertRuntimeGenerationOpen?.();
if (location === 'conflict') {
throw new SessionConflictError(sessionId);
}
const renamed = location
? await service.renameSession(
sessionId,
displayName,
'manual',
location,
)
: false;
assertRuntimeGenerationOpen?.();
if (!renamed) {
throw new SessionNotFoundError(sessionId);
}
effective = { displayName: displayName || undefined };
}
invalidateSessionLists(runtime, ['active', 'archived']);
res.status(200).json({ sessionId, ...effective });
});
});
} catch (err) {
sendBridgeError(res, err, {
route,
sessionId,
workspaceCwd: runtime.workspaceCwd,
});
}
},
);
type SessionOrganizationTarget = {
runtime?: WorkspaceRuntime;
resolveRuntime?: (

View file

@ -641,6 +641,7 @@ const EXPECTED_STAGE1_FEATURES = [
'workspace_persisted_transcript',
'workspace_session_export',
'workspace_archived_session_export',
'workspace_session_metadata',
// Baseline (always advertised) — presence means the `/voice/stream`
// endpoint exists; the WS errors if no voice model is configured.
'voice_transcribe',
@ -700,6 +701,7 @@ const EXPECTED_REGISTERED_FEATURES = [
f !== 'workspace_persisted_transcript' &&
f !== 'workspace_session_export' &&
f !== 'workspace_archived_session_export' &&
f !== 'workspace_session_metadata' &&
f !== 'voice_transcribe' &&
f !== 'realtime_voice',
),
@ -755,6 +757,7 @@ const EXPECTED_REGISTERED_FEATURES = [
'workspace_persisted_transcript',
'workspace_session_export',
'workspace_archived_session_export',
'workspace_session_metadata',
'workspace_qualified_acp',
'client_mcp_over_ws',
'cdp_tunnel_over_ws',
@ -23363,6 +23366,47 @@ describe('createServeApp', () => {
req
.set('Host', `127.0.0.1:${tokenOpts.port}`)
.set('Authorization', 'Bearer secret');
const createWorkspaceMetadataApp = (
secondaryBridge: FakeBridge,
options: {
trusted?: boolean;
sessionRuntimeBaseDir?: string;
primaryBridge?: FakeBridge;
generationGuard?: WorkspaceGenerationGuard;
} = {},
) => {
const primaryBridge = options.primaryBridge ?? fakeBridge();
const registry = createWorkspaceRegistry([
makeWorkspaceRuntimeForTest({
workspaceId: 'ws-primary',
workspaceCwd: WS_BOUND,
primary: true,
bridge: primaryBridge,
}),
makeWorkspaceRuntimeForTest({
workspaceId: 'ws-secondary',
workspaceCwd: WS_DIFFERENT,
primary: false,
bridge: secondaryBridge,
...(options.trusted !== undefined
? { trusted: options.trusted }
: {}),
...(options.sessionRuntimeBaseDir !== undefined
? { sessionRuntimeBaseDir: options.sessionRuntimeBaseDir }
: {}),
...(options.generationGuard
? { generationGuard: options.generationGuard }
: {}),
}),
]);
return {
app: createServeApp(tokenOpts, undefined, {
workspaceRegistry: registry,
}),
primaryBridge,
registry,
};
};
it('200 on successful metadata update', async () => {
const bridge = fakeBridge();
@ -23458,6 +23502,311 @@ describe('createServeApp', () => {
expect(res.status).toBe(400);
expect(res.body.code).toBe('invalid_metadata');
});
it('updates the selected workspace runtime with client identity', async () => {
const secondaryBridge = fakeBridge();
const { app, primaryBridge } =
createWorkspaceMetadataApp(secondaryBridge);
const res = await auth(
request(app).patch(
'/workspaces/ws-secondary/session/session-A/metadata',
),
)
.set('X-Qwen-Client-Id', 'client-1')
.send({ displayName: 'Secondary session' });
expect(res.status).toBe(200);
expect(secondaryBridge.updateMetadataCalls).toEqual([
{
sessionId: 'session-A',
metadata: { displayName: 'Secondary session' },
context: { clientId: 'client-1' },
},
]);
expect(primaryBridge.updateMetadataCalls).toEqual([]);
});
it('fails closed when the live session owner is unavailable', async () => {
const secondaryBridge = fakeBridge();
const { app, registry } = createWorkspaceMetadataApp(secondaryBridge);
vi.spyOn(registry, 'resolveLiveSessionOwner').mockReturnValue({
kind: 'unavailable',
});
const res = await auth(
request(app).patch(
'/workspaces/ws-secondary/session/session-A/metadata',
),
).send({ displayName: 'Blocked' });
expect(res.status).toBe(503);
expect(res.body.code).toBe('workspace_runtime_unavailable');
expect(secondaryBridge.updateMetadataCalls).toEqual([]);
});
it('fails closed when the selected workspace generation closes', async () => {
const generationGuard = createWorkspaceGenerationGuard();
const secondaryBridge = fakeBridge({
updateMetadataImpl: (_sessionId, metadata) => {
generationGuard.close();
return metadata;
},
});
const { app } = createWorkspaceMetadataApp(secondaryBridge, {
generationGuard,
});
const res = await auth(
request(app).patch(
'/workspaces/ws-secondary/session/session-A/metadata',
),
).send({ displayName: 'Blocked' });
expect(res.status).toBe(503);
expect(res.body.code).toBe('workspace_runtime_unavailable');
});
it.each([
[{}, 'displayName'],
[{ displayName: 123 }, 'displayName'],
[{ displayName: '' }, 'displayName'],
[{ displayName: ' ' }, 'displayName'],
[{ displayName: 'bad\nname' }, 'displayName'],
] as const)(
'rejects invalid workspace metadata %#',
async (body, field) => {
const secondaryBridge = fakeBridge();
const { app } = createWorkspaceMetadataApp(secondaryBridge);
const res = await auth(
request(app).patch(
'/workspaces/ws-secondary/session/session-A/metadata',
),
).send(body);
expect(res.status).toBe(400);
expect(res.body).toMatchObject({ code: 'invalid_metadata', field });
expect(secondaryBridge.updateMetadataCalls).toEqual([]);
},
);
it('clamps workspace metadata displayName to 256 characters', async () => {
const secondaryBridge = fakeBridge();
const { app } = createWorkspaceMetadataApp(secondaryBridge);
const res = await auth(
request(app).patch(
'/workspaces/ws-secondary/session/session-A/metadata',
),
).send({ displayName: 'x'.repeat(300) });
expect(res.status).toBe(200);
expect(res.body).toEqual({
sessionId: 'session-A',
displayName: 'x'.repeat(256),
});
expect(secondaryBridge.updateMetadataCalls).toEqual([
{
sessionId: 'session-A',
metadata: { displayName: 'x'.repeat(256) },
},
]);
});
it('rejects metadata updates for an untrusted workspace', async () => {
const secondaryBridge = fakeBridge();
const { app } = createWorkspaceMetadataApp(secondaryBridge, {
trusted: false,
});
const res = await auth(
request(app).patch(
'/workspaces/ws-secondary/session/session-A/metadata',
),
).send({ displayName: 'Blocked' });
expect(res.status).toBe(403);
expect(secondaryBridge.updateMetadataCalls).toEqual([]);
});
it.each(['active', 'archived'] as const)(
'renames a persisted %s session in the selected workspace',
async (state) => {
const runtimeBaseDir = await fsp.mkdtemp(
path.join(os.tmpdir(), 'qwen-workspace-metadata-'),
);
const sessionId = `550e8400-e29b-41d4-a716-4466554400${
state === 'active' ? '31' : '32'
}`;
const chatsDir = path.join(
new Storage(WS_DIFFERENT, runtimeBaseDir).getProjectDir(),
'chats',
...(state === 'archived' ? ['archive'] : []),
);
const filePath = path.join(chatsDir, `${sessionId}.jsonl`);
await fsp.mkdir(chatsDir, { recursive: true });
await fsp.writeFile(
filePath,
`${JSON.stringify({
uuid: 'record-1',
parentUuid: null,
sessionId,
timestamp: '2026-05-17T12:00:00.000Z',
type: 'user',
message: { role: 'user', parts: [{ text: 'original' }] },
cwd: WS_DIFFERENT,
})}\n`,
'utf8',
);
const secondaryBridge = fakeBridge({
updateMetadataImpl: () => {
throw new SessionNotFoundError(sessionId);
},
});
const { app } = createWorkspaceMetadataApp(secondaryBridge, {
sessionRuntimeBaseDir: runtimeBaseDir,
});
try {
const res = await auth(
request(app).patch(
`/workspaces/ws-secondary/session/${sessionId}/metadata`,
),
).send({ displayName: 'Persisted rename' });
expect(res.status).toBe(200);
expect(res.body).toEqual({
sessionId,
displayName: 'Persisted rename',
});
expect(await fsp.readFile(filePath, 'utf8')).toContain(
'Persisted rename',
);
} finally {
await fsp.rm(runtimeBaseDir, { recursive: true, force: true });
}
},
);
it('returns 404 for a missing persisted session and 409 for a store conflict', async () => {
const runtimeBaseDir = await fsp.mkdtemp(
path.join(os.tmpdir(), 'qwen-workspace-metadata-conflict-'),
);
const sessionId = '550e8400-e29b-41d4-a716-446655440033';
const secondaryBridge = fakeBridge({
updateMetadataImpl: () => {
throw new SessionNotFoundError(sessionId);
},
});
const { app } = createWorkspaceMetadataApp(secondaryBridge, {
sessionRuntimeBaseDir: runtimeBaseDir,
});
const patchMetadata = () =>
auth(
request(app).patch(
`/workspaces/ws-secondary/session/${sessionId}/metadata`,
),
).send({ displayName: 'Rename' });
try {
expect((await patchMetadata()).status).toBe(404);
const chatsDir = path.join(
new Storage(WS_DIFFERENT, runtimeBaseDir).getProjectDir(),
'chats',
);
const record = `${JSON.stringify({
uuid: 'record-1',
parentUuid: null,
sessionId,
timestamp: '2026-05-17T12:00:00.000Z',
type: 'user',
message: { role: 'user', parts: [{ text: 'original' }] },
cwd: WS_DIFFERENT,
})}\n`;
await fsp.mkdir(path.join(chatsDir, 'archive'), { recursive: true });
await Promise.all([
fsp.writeFile(path.join(chatsDir, `${sessionId}.jsonl`), record),
fsp.writeFile(
path.join(chatsDir, 'archive', `${sessionId}.jsonl`),
record,
),
]);
const conflict = await patchMetadata();
expect(conflict.status).toBe(409);
expect(conflict.body).toMatchObject({
code: 'session_conflict',
sessionId,
});
} finally {
await fsp.rm(runtimeBaseDir, { recursive: true, force: true });
}
});
it('rejects a rename when the session is live in another workspace runtime', async () => {
const runtimeBaseDir = await fsp.mkdtemp(
path.join(os.tmpdir(), 'qwen-workspace-metadata-live-owner-'),
);
const sessionId = '550e8400-e29b-41d4-a716-446655440034';
const chatsDir = path.join(
new Storage(WS_DIFFERENT, runtimeBaseDir).getProjectDir(),
'chats',
);
const filePath = path.join(chatsDir, `${sessionId}.jsonl`);
await fsp.mkdir(chatsDir, { recursive: true });
await fsp.writeFile(
filePath,
`${JSON.stringify({
uuid: 'record-1',
parentUuid: null,
sessionId,
timestamp: '2026-05-17T12:00:00.000Z',
type: 'user',
message: { role: 'user', parts: [{ text: 'original' }] },
cwd: WS_DIFFERENT,
})}\n`,
'utf8',
);
const primaryBridge = fakeBridge({
summaryImpl: (id: string) => ({
sessionId: id,
workspaceCwd: WS_BOUND,
createdAt: '2026-05-17T12:00:00.000Z',
clientCount: 1,
hasActivePrompt: false,
}),
});
const secondaryBridge = fakeBridge({
updateMetadataImpl: () => {
throw new SessionNotFoundError(sessionId);
},
});
const { app } = createWorkspaceMetadataApp(secondaryBridge, {
sessionRuntimeBaseDir: runtimeBaseDir,
primaryBridge,
});
try {
const res = await auth(
request(app).patch(
`/workspaces/ws-secondary/session/${sessionId}/metadata`,
),
).send({ displayName: 'Live elsewhere' });
expect(res.status).toBe(409);
expect(res.body).toMatchObject({
code: 'session_workspace_conflict',
sessionId,
workspaceCwd: WS_DIFFERENT,
liveWorkspaceCwd: WS_BOUND,
liveWorkspaceId: 'ws-primary',
});
expect(secondaryBridge.updateMetadataCalls).toEqual([]);
expect(primaryBridge.updateMetadataCalls).toEqual([]);
expect(await fsp.readFile(filePath, 'utf8')).not.toContain(
'Live elsewhere',
);
} finally {
await fsp.rm(runtimeBaseDir, { recursive: true, force: true });
}
});
});
describe('POST /session/:id/heartbeat', () => {

View file

@ -116,6 +116,22 @@ describe('SessionService - rename and custom title', () => {
expect(writtenRecord.sessionId).toBe(sessionIdA);
});
it('should rename a session in the archive store', async () => {
vi.mocked(jsonl.readLines).mockResolvedValue([recordA1]);
const result = await sessionService.renameSession(
sessionIdA,
'archived session',
'manual',
'archived',
);
expect(result).toBe(true);
expect(vi.mocked(jsonl.writeLineSync).mock.calls[0][0]).toContain(
`/archive/${sessionIdA}.jsonl`,
);
});
it('should return false when session does not exist', async () => {
vi.mocked(jsonl.readLines).mockResolvedValue([]);

View file

@ -1795,20 +1795,19 @@ export class SessionService {
* @param titleSource Where the title came from. Defaults to `'manual'` so
* existing callers are unchanged pass `'auto'` only for titles produced
* by the auto-title generator.
* @param archiveState Which session store to rename. Defaults to active.
* @returns true if renamed successfully, false if session not found
* @remarks Only checks active sessions. Use `getSessionLocation()` or
* `sessionExistsInAnyState()` for archive-aware lookups.
*/
async renameSession(
sessionId: string,
title: string,
titleSource: TitleSource = 'manual',
archiveState: SessionArchiveState = 'active',
): Promise<boolean> {
if (!SESSION_FILE_PATTERN.test(`${sessionId}.jsonl`)) {
return false;
}
const chatsDir = this.getChatsDir();
const filePath = path.join(chatsDir, `${sessionId}.jsonl`);
const filePath = this.getSessionFilePath(sessionId, archiveState);
try {
// Verify the file exists and belongs to this project

View file

@ -5933,6 +5933,19 @@ export class WorkspaceDaemonClient {
);
}
updateSessionMetadata(
sessionId: string,
metadata: { displayName: string },
clientId?: string,
): Promise<SessionMetadataResult> {
return this.client.workspaceJsonRequest<SessionMetadataResult>(
this.workspaceSelector,
`/session/${urlEncode(sessionId)}/metadata`,
'PATCH /workspaces/:workspace/session/:id/metadata',
{ method: 'PATCH', body: metadata, clientId, mode: 'rest' },
);
}
listSessionGroups(): Promise<DaemonSessionGroupCatalog> {
return this.get(
'/session-groups',

View file

@ -7367,6 +7367,54 @@ describe('DaemonClient', () => {
}
});
it('workspace metadata update uses encoded direct REST and client identity', async () => {
const { fetch, calls } = recordingFetch(() =>
jsonResponse(200, {
sessionId: 'session/1',
displayName: 'Renamed',
}),
);
const transportFetch = vi.fn(async () =>
jsonResponse(404, { error: 'transport route not mapped' }),
);
const transport: DaemonTransport = {
type: 'acp-http',
supportsReplay: true,
connected: true,
fetch: transportFetch,
async *subscribeEvents() {},
dispose() {},
};
const client = new DaemonClient({
baseUrl: 'http://daemon',
fetch,
transport,
});
await expect(
client
.workspaceByCwd('/tmp/work space')
.updateSessionMetadata(
'session/1',
{ displayName: 'Renamed' },
'client-1',
),
).resolves.toEqual({
sessionId: 'session/1',
displayName: 'Renamed',
});
expect(transportFetch).not.toHaveBeenCalled();
expect(calls[0]).toMatchObject({
method: 'PATCH',
url: 'http://daemon/workspaces/%2Ftmp%2Fwork%20space/session/session%2F1/metadata',
headers: { 'x-qwen-client-id': 'client-1' },
});
expect(JSON.parse(calls[0]!.body!)).toEqual({
displayName: 'Renamed',
});
});
it('workspace transcript paging forces direct REST transport', async () => {
const body = {
v: 1 as const,

View file

@ -978,14 +978,14 @@
.gitBranchIcon {
display: inline-flex;
width: 16px;
height: 16px;
flex: 0 0 16px;
width: var(--git-branch-icon-size, 16px);
height: var(--git-branch-icon-size, 16px);
flex: 0 0 var(--git-branch-icon-size, 16px);
}
.gitBranchIcon svg {
width: 16px;
height: 16px;
width: var(--git-branch-icon-size, 16px);
height: var(--git-branch-icon-size, 16px);
}
.gitBranchText {
@ -1006,17 +1006,17 @@
.gitBranchIconWrap {
position: relative;
display: inline-flex;
flex: 0 0 16px;
flex: 0 0 var(--git-branch-icon-size, 16px);
}
/* Compact (icon-only) chip: a single severity dot on the branch icon so the
working-tree state is still glanceable without the text + inline indicators. */
.gitBranchBadgeDot {
position: absolute;
top: -2px;
right: -3px;
width: 7px;
height: 7px;
top: var(--git-branch-badge-offset, -2px);
right: var(--git-branch-badge-offset, -3px);
width: var(--git-branch-badge-size, 7px);
height: var(--git-branch-badge-size, 7px);
border-radius: 50%;
background: var(--chat-editor-accent-color);
box-shadow: 0 0 0 1.5px var(--chat-editor-bg-primary, transparent);

View file

@ -536,17 +536,16 @@ describe('ChatEditor context usage ring', () => {
});
expect(document.body.textContent).toContain('53.6k / 1.0M tokens (5.4%)');
// The arrow must be the Radix-positioned element: a pseudo-element pinned
// to the content center stops pointing at the trigger once collision
// avoidance shifts the content near the viewport edge. jsdom has no
// layout, so pin the positioning classes the rendered arrow depends on —
// a shadcn regeneration that drops them would detach the arrow visually
// while an existence check stayed green.
const arrowClass = document
.querySelector('[data-slot="tooltip-arrow"]')
?.getAttribute('class');
expect(arrowClass).toContain('rotate-45');
expect(arrowClass).toContain('translate-y-[calc(-50%_-_2px)]');
const arrow = document.querySelector<SVGElement>(
'[data-slot="tooltip-arrow"]',
);
expect(arrow?.querySelectorAll('path')).toHaveLength(2);
expect(arrow?.style.transform).toBe(
'translateY(var(--floating-arrow-offset))',
);
expect(
arrow?.closest('[data-slot="tooltip-content"]')?.getAttribute('class'),
).toContain('[--floating-arrow-offset:-1px]');
});
it('escalates the arc color at the /context panel thresholds', () => {

View file

@ -1,176 +0,0 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import type { DaemonSessionSummary } from '@qwen-code/sdk/daemon';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuTrigger,
} from '../ui/dropdown-menu';
const { I18nProvider } = await import('../../i18n');
const { SessionDetailsSubmenu } = await import('./SessionDetailsSubmenu');
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
if (!globalThis.PointerEvent) {
globalThis.PointerEvent = MouseEvent as typeof PointerEvent;
}
if (!Element.prototype.hasPointerCapture) {
Element.prototype.hasPointerCapture = () => false;
}
if (!Element.prototype.setPointerCapture) {
Element.prototype.setPointerCapture = () => {};
}
if (!Element.prototype.releasePointerCapture) {
Element.prototype.releasePointerCapture = () => {};
}
const session = {
sessionId: 'session-id-copied-by-real-radix-keyboard-interaction',
displayName: 'Keyboard session',
clientCount: 1,
hasActivePrompt: false,
} as DaemonSessionSummary;
let root: Root;
let container: HTMLDivElement;
let webShellRoot: HTMLDivElement;
let clipboardDescriptor: PropertyDescriptor | undefined;
function render(onError: ReturnType<typeof vi.fn>): void {
act(() => {
root.render(
<I18nProvider language="en">
<div
ref={(element) => {
if (element) webShellRoot = element;
}}
data-web-shell-root
>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<button type="button">More actions</button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuGroup>
<SessionDetailsSubmenu
session={session}
label="Keyboard session"
completedUnread={false}
onError={onError}
getCollisionBoundary={() => webShellRoot}
/>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</I18nProvider>,
);
});
}
async function click(element: HTMLElement): Promise<void> {
await act(async () => {
element.dispatchEvent(
new PointerEvent('pointerdown', {
bubbles: true,
button: 0,
pointerType: 'mouse',
}),
);
element.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
});
}
async function pressKey(element: HTMLElement, key: string): Promise<void> {
await act(async () => {
element.dispatchEvent(
new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }),
);
await Promise.resolve();
});
}
async function focus(element: HTMLElement): Promise<void> {
await act(async () => {
element.focus();
await Promise.resolve();
});
}
function getMenuItem(label: string): HTMLElement {
const item = document.body.querySelector<HTMLElement>(
`[data-slot="dropdown-menu-item"][aria-label="${label}"]`,
);
expect(item).not.toBeNull();
return item!;
}
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
clipboardDescriptor = Object.getOwnPropertyDescriptor(navigator, 'clipboard');
});
afterEach(() => {
act(() => root.unmount());
container.remove();
if (clipboardDescriptor) {
Object.defineProperty(navigator, 'clipboard', clipboardDescriptor);
} else {
Reflect.deleteProperty(navigator, 'clipboard');
}
vi.restoreAllMocks();
});
describe('SessionDetailsSubmenu keyboard behavior', () => {
it('copies through Enter and Space on the real Radix menu item', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
});
render(vi.fn());
const actions = Array.from(container.querySelectorAll('button')).find(
(button) => button.textContent === 'More actions',
);
expect(actions).toBeDefined();
await click(actions!);
const details = document.body.querySelector<HTMLElement>(
'[data-slot="dropdown-menu-sub-trigger"]',
);
expect(details).not.toBeNull();
await focus(details!);
await pressKey(details!, 'ArrowRight');
const copy = getMenuItem('Copy session ID');
const detailsContent = document.body.querySelector<HTMLElement>(
'[data-slot="dropdown-menu-sub-content"]',
);
expect(detailsContent?.classList.contains('min-w-0')).toBe(true);
expect(detailsContent?.classList.contains('min-w-[96px]')).toBe(false);
expect(detailsContent?.classList.contains('p-3')).toBe(true);
expect(detailsContent?.classList.contains('p-1')).toBe(false);
expect(copy.classList.contains('cursor-pointer')).toBe(true);
expect(copy.classList.contains('cursor-default')).toBe(false);
await focus(copy);
expect(document.activeElement).toBe(copy);
await pressKey(copy, 'Enter');
expect(document.activeElement).toBe(copy);
await pressKey(copy, ' ');
expect(document.activeElement).toBe(copy);
expect(writeText).toHaveBeenNthCalledWith(1, session.sessionId);
expect(writeText).toHaveBeenNthCalledWith(2, session.sessionId);
expect(document.body.querySelector('[role="status"]')?.textContent).toBe(
'Session ID copied',
);
});
});

View file

@ -1,401 +0,0 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
act,
forwardRef,
type ButtonHTMLAttributes,
type PropsWithChildren,
} from 'react';
import { createPortal } from 'react-dom';
import { createRoot, type Root } from 'react-dom/client';
import type { DaemonSessionSummary } from '@qwen-code/sdk/daemon';
const { dropdownMenu } = vi.hoisted(() => ({
dropdownMenu: {
subContentProps: null as unknown,
subContentPropsHistory: [] as unknown[],
open: false,
onOpenChange: null as ((open: boolean) => void) | null,
copyItemOnSelect: null as ((event: Event) => void) | null,
portalRoot: null as HTMLElement | null,
},
}));
vi.mock('../ui/dropdown-menu', () => {
function DropdownMenuSub({
children,
open = false,
onOpenChange = () => {},
}: PropsWithChildren<{
open?: boolean;
onOpenChange?: (open: boolean) => void;
}>) {
dropdownMenu.open = open;
dropdownMenu.onOpenChange = onOpenChange;
return dropdownMenu.portalRoot
? createPortal(children, dropdownMenu.portalRoot)
: children;
}
const DropdownMenuSubTrigger = forwardRef<
HTMLButtonElement,
ButtonHTMLAttributes<HTMLButtonElement>
>(function DropdownMenuSubTrigger({ onClick, ...props }, ref) {
return (
<button
{...props}
ref={ref}
type="button"
onClick={(event) => {
onClick?.(event);
const nextOpen = !dropdownMenu.open;
dropdownMenu.onOpenChange?.(nextOpen);
}}
/>
);
});
function DropdownMenuSubContent({
children,
avoidCollisions,
collisionBoundary,
collisionPadding,
updatePositionStrategy,
className,
}: PropsWithChildren<{
avoidCollisions?: boolean;
collisionBoundary?: Element;
collisionPadding?: number;
updatePositionStrategy?: 'optimized' | 'always';
className?: string;
}>) {
const subContentProps = {
avoidCollisions,
collisionBoundary,
collisionPadding,
updatePositionStrategy,
className,
};
dropdownMenu.subContentProps = subContentProps;
dropdownMenu.subContentPropsHistory.push(subContentProps);
return (
<div data-slot="dropdown-menu-sub-content" className={className}>
{children}
</div>
);
}
function DropdownMenuItem({
children,
onSelect,
...props
}: PropsWithChildren<{
onSelect?: (event: Event) => void;
}>) {
dropdownMenu.copyItemOnSelect = onSelect ?? null;
return (
<div role="menuitem" {...props}>
{children}
</div>
);
}
return {
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuItem,
};
});
const { I18nProvider } = await import('../../i18n');
const { SessionDetailsSubmenu } = await import('./SessionDetailsSubmenu');
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
const session = {
sessionId:
'session-id-that-remains-complete-when-the-visible-value-is-truncated',
displayName: 'Long running session',
clientCount: 2,
hasActivePrompt: true,
} as DaemonSessionSummary;
let root: Root;
let container: HTMLDivElement;
let webShellRoot: HTMLDivElement;
let portalRoot: HTMLDivElement;
let onError: ReturnType<typeof vi.fn>;
let getCollisionBoundary: ReturnType<typeof vi.fn>;
let clipboardDescriptor: PropertyDescriptor | undefined;
function render(sessionOverride: DaemonSessionSummary = session): void {
act(() => {
root.render(
<I18nProvider language="en">
<div
ref={(element) => {
if (element) {
webShellRoot = element;
}
}}
data-web-shell-root
>
<aside>
<SessionDetailsSubmenu
session={sessionOverride}
label="Long running session"
completedUnread
onError={onError}
getCollisionBoundary={getCollisionBoundary}
/>
</aside>
</div>
</I18nProvider>,
);
});
}
function getMenuItem(label: string): HTMLElement {
const item = portalRoot.querySelector<HTMLElement>(
`[role="menuitem"][aria-label="${label}"]`,
);
expect(item).not.toBeNull();
return item!;
}
function getStatus(): HTMLElement {
const status = portalRoot.querySelector<HTMLElement>('[role="status"]');
expect(status).not.toBeNull();
return status!;
}
async function openDetails(): Promise<void> {
const details = Array.from(portalRoot.querySelectorAll('button')).find(
(button) => button.textContent?.includes('Details'),
);
expect(details).not.toBeUndefined();
await click(details!);
}
async function click(element: HTMLElement): Promise<void> {
await act(async () => {
element.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
});
}
async function selectCopy(): Promise<Event> {
const onSelect = dropdownMenu.copyItemOnSelect;
expect(onSelect).not.toBeNull();
const event = new Event('select', { cancelable: true });
await act(async () => {
onSelect!(event);
await Promise.resolve();
});
return event;
}
function mockClipboard(writeText: ReturnType<typeof vi.fn>): void {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
});
}
function deferred<T>(): {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (reason?: unknown) => void;
} {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
}
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
portalRoot = document.createElement('div');
portalRoot.dataset.webShellPortalRoot = '';
document.body.appendChild(portalRoot);
root = createRoot(container);
onError = vi.fn();
getCollisionBoundary = vi.fn(() => webShellRoot);
clipboardDescriptor = Object.getOwnPropertyDescriptor(navigator, 'clipboard');
dropdownMenu.subContentProps = null;
dropdownMenu.subContentPropsHistory = [];
dropdownMenu.open = false;
dropdownMenu.onOpenChange = null;
dropdownMenu.copyItemOnSelect = null;
dropdownMenu.portalRoot = portalRoot;
});
afterEach(() => {
act(() => root.unmount());
container.remove();
portalRoot.remove();
if (clipboardDescriptor) {
Object.defineProperty(navigator, 'clipboard', clipboardDescriptor);
} else {
Reflect.deleteProperty(navigator, 'clipboard');
}
vi.restoreAllMocks();
});
describe('SessionDetailsSubmenu', () => {
it('uses the non-portal WebShell root for narrow-screen collision flipping', async () => {
render();
dropdownMenu.subContentPropsHistory = [];
await openDetails();
const firstOpenProps = dropdownMenu.subContentPropsHistory.at(0) as {
collisionBoundary?: Element;
};
const subContentProps = dropdownMenu.subContentProps as {
avoidCollisions?: boolean;
collisionBoundary?: Element;
collisionPadding?: number;
updatePositionStrategy?: 'optimized' | 'always';
};
expect(getCollisionBoundary).toHaveBeenCalled();
expect(firstOpenProps.collisionBoundary).toBe(webShellRoot);
expect(subContentProps.avoidCollisions).toBe(true);
expect(subContentProps.collisionBoundary).toBe(webShellRoot);
expect(subContentProps.collisionBoundary).not.toBe(portalRoot);
expect(subContentProps.collisionPadding).toBe(8);
expect(subContentProps.updatePositionStrategy).toBe('always');
});
it('copies the complete session ID through the copy menu item', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
mockClipboard(writeText);
render();
await openDetails();
expect(getMenuItem('Copy session ID')).not.toBeNull();
expect(getStatus().textContent).toBe('');
const selectEvent = await selectCopy();
expect(selectEvent.defaultPrevented).toBe(true);
expect(writeText).toHaveBeenCalledWith(session.sessionId);
expect(getStatus().textContent).toBe('Session ID copied');
expect(getStatus().getAttribute('aria-live')).toBe('polite');
});
it('clears copied feedback when Details closes or its session changes', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
mockClipboard(writeText);
render();
await openDetails();
await selectCopy();
expect(getStatus().textContent).toBe('Session ID copied');
await openDetails();
expect(getStatus().textContent).toBe('');
await openDetails();
await selectCopy();
expect(getStatus().textContent).toBe('Session ID copied');
render({ ...session, sessionId: 'different-session-id' });
expect(getStatus().textContent).toBe('');
});
it('ignores a pending copy result after Details closes and reopens', async () => {
const pendingCopy = deferred<void>();
mockClipboard(vi.fn().mockReturnValue(pendingCopy.promise));
render();
await openDetails();
await selectCopy();
await openDetails();
await openDetails();
await act(async () => {
pendingCopy.resolve(undefined);
await pendingCopy.promise;
});
expect(getStatus().textContent).toBe('');
expect(onError).not.toHaveBeenCalled();
});
it('only applies the latest copy attempt for the current session', async () => {
const firstCopy = deferred<void>();
const secondCopy = deferred<void>();
const staleError = new Error('stale clipboard failure');
mockClipboard(
vi
.fn()
.mockReturnValueOnce(firstCopy.promise)
.mockReturnValueOnce(secondCopy.promise),
);
render();
await openDetails();
await selectCopy();
await selectCopy();
await act(async () => {
secondCopy.resolve(undefined);
await secondCopy.promise;
});
expect(getStatus().textContent).toBe('Session ID copied');
await act(async () => {
firstCopy.reject(staleError);
await firstCopy.promise.catch(() => undefined);
});
expect(getStatus().textContent).toBe('Session ID copied');
expect(onError).not.toHaveBeenCalled();
});
it('ignores a pending copy failure after the session changes', async () => {
const pendingCopy = deferred<void>();
const staleError = new Error('stale clipboard failure');
mockClipboard(vi.fn().mockReturnValue(pendingCopy.promise));
render();
await openDetails();
await selectCopy();
render({ ...session, sessionId: 'different-session-id' });
await act(async () => {
pendingCopy.reject(staleError);
await pendingCopy.promise.catch(() => undefined);
});
expect(getStatus().textContent).toBe('');
expect(onError).not.toHaveBeenCalled();
});
it('reports clipboard failures through the existing error path', async () => {
const error = new Error('clipboard denied');
mockClipboard(vi.fn().mockRejectedValue(error));
render();
await openDetails();
await selectCopy();
expect(getStatus().textContent).toBe('');
expect(onError).toHaveBeenCalledWith(error, 'Failed to copy session ID');
});
it('reports an unavailable clipboard through the existing error path', async () => {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: undefined,
});
render();
await openDetails();
await selectCopy();
expect(getStatus().textContent).toBe('');
expect(onError).toHaveBeenCalledWith(
expect.objectContaining({ message: 'Clipboard API is unavailable' }),
'Failed to copy session ID',
);
});
});

View file

@ -1,151 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { DaemonSessionSummary } from '@qwen-code/sdk/daemon';
import { CheckIcon, CopyIcon, InfoIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useI18n } from '../../i18n';
import {
DropdownMenuItem,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
} from '../ui/dropdown-menu';
import styles from './WebShellSidebar.module.css';
interface SessionDetailsSubmenuProps {
session: DaemonSessionSummary;
label: string;
completedUnread: boolean;
onError: (error: unknown, fallback: string) => void;
getCollisionBoundary: () => HTMLElement | null;
}
const COLLISION_PADDING = 8;
export function SessionDetailsSubmenu({
session,
label,
completedUnread,
onError,
getCollisionBoundary,
}: SessionDetailsSubmenuProps) {
const { t } = useI18n();
const [open, setOpen] = useState(false);
const [copied, setCopied] = useState(false);
const copyAttemptRef = useRef(0);
const currentSessionIdRef = useRef(session.sessionId);
currentSessionIdRef.current = session.sessionId;
const collisionBoundary = open ? getCollisionBoundary() : null;
useEffect(() => {
setCopied(false);
}, [session.sessionId]);
useEffect(
() => () => {
copyAttemptRef.current += 1;
},
[],
);
const handleOpenChange = useCallback((nextOpen: boolean) => {
if (!nextOpen) {
copyAttemptRef.current += 1;
}
setCopied(false);
setOpen(nextOpen);
}, []);
const copySessionId = useCallback(async () => {
const sessionId = session.sessionId;
const copyAttempt = ++copyAttemptRef.current;
try {
if (typeof navigator === 'undefined' || !navigator.clipboard?.writeText) {
throw new Error('Clipboard API is unavailable');
}
await navigator.clipboard.writeText(sessionId);
if (
copyAttemptRef.current !== copyAttempt ||
currentSessionIdRef.current !== sessionId
) {
return;
}
setCopied(true);
} catch (error: unknown) {
if (
copyAttemptRef.current !== copyAttempt ||
currentSessionIdRef.current !== sessionId
) {
return;
}
setCopied(false);
onError(error, t('sidebar.copySessionIdFailed'));
}
}, [onError, session.sessionId, t]);
return (
<DropdownMenuSub open={open} onOpenChange={handleOpenChange}>
<DropdownMenuSubTrigger>
<InfoIcon />
{t('sidebar.details')}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent
avoidCollisions
collisionBoundary={collisionBoundary ?? undefined}
collisionPadding={COLLISION_PADDING}
// Radix's optimized strategy does not observe the non-portal WebShell root.
updatePositionStrategy="always"
className={cn(styles.sessionDetailsContent, 'min-w-0 p-3')}
>
<div className={styles.tooltipContent}>
<div className={styles.tooltipTitle} title={label}>
{label}
</div>
<div className={styles.tooltipTags}>
{session.hasActivePrompt && (
<span
className={`${styles.tooltipTag} ${styles.tooltipTagRunning}`}
>
{t('sidebar.running')}
</span>
)}
{completedUnread && (
<span className={`${styles.tooltipTag} ${styles.tooltipTagNew}`}>
{t('sidebar.completedUnread')}
</span>
)}
<span className={styles.tooltipTag}>
{t('sidebar.clients', { count: session.clientCount ?? 0 })}
</span>
</div>
<div className={styles.sessionDetailsIdRow}>
<span className={styles.sessionDetailsId} title={session.sessionId}>
{session.sessionId}
</span>
<DropdownMenuItem
className={cn(styles.sessionDetailsCopyButton, 'cursor-pointer')}
aria-label={t('sidebar.copySessionId')}
title={t('sidebar.copySessionId')}
onSelect={(event) => {
event.preventDefault();
void copySessionId();
}}
>
{copied ? (
<CheckIcon aria-hidden="true" />
) : (
<CopyIcon aria-hidden="true" />
)}
</DropdownMenuItem>
</div>
<span
className={styles.sessionDetailsCopied}
role="status"
aria-live="polite"
>
{copied ? t('sidebar.sessionIdCopied') : ''}
</span>
</div>
</DropdownMenuSubContent>
</DropdownMenuSub>
);
}

View file

@ -0,0 +1,324 @@
// @vitest-environment jsdom
import { act } from 'react';
import { createPortal } from 'react-dom';
import { createRoot } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { I18nProvider } from '../../i18n';
import { SessionDetailsTooltip } from './SessionDetailsTooltip';
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
function deferred<T>(): {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (reason?: unknown) => void;
} {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
}
async function openDetails(container: HTMLElement) {
const trigger = container.querySelector('button');
if (!trigger) throw new Error('trigger was not rendered');
await act(async () => {
trigger.dispatchEvent(new Event('pointerover', { bubbles: true }));
vi.advanceTimersByTime(300);
await Promise.resolve();
});
}
afterEach(() => {
vi.useRealTimers();
Reflect.deleteProperty(navigator, 'clipboard');
document.body.replaceChildren();
});
describe('SessionDetailsTooltip', () => {
it('shows the same structured details on row hover', async () => {
vi.useFakeTimers();
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(
<I18nProvider language="en">
<SessionDetailsTooltip
session={{
sessionId: 'session-1',
workspaceCwd: '/work/qwen-code',
clientCount: 2,
branch: { name: 'codex/sidebar', baseBranch: 'main' },
}}
label="Improve sidebar"
time="2 weeks ago"
completedUnread={false}
>
<button type="button">Improve sidebar</button>
</SessionDetailsTooltip>
</I18nProvider>,
);
});
await openDetails(container);
const details = document.querySelector('[role="dialog"]');
expect(details?.textContent).toContain('Improve sidebar');
expect(details?.textContent).toContain('2 weeks ago');
expect(details?.textContent).toContain('qwen-code');
expect(details?.querySelector('[title="/work/qwen-code"]')).not.toBeNull();
expect(details?.textContent).toContain('codex/sidebar');
expect(details?.textContent).toContain('2 client(s)');
expect(details?.querySelector('svg path.fill-popover')).not.toBeNull();
act(() => root.unmount());
});
it('does not reopen after a row action opens its menu', async () => {
vi.useFakeTimers();
const container = document.createElement('div');
const portalRoot = document.createElement('div');
document.body.appendChild(container);
document.body.appendChild(portalRoot);
const root = createRoot(container);
act(() => {
root.render(
<I18nProvider language="en">
<SessionDetailsTooltip
session={{ sessionId: 'session-1', workspaceCwd: '/work/repo' }}
label="Session"
time=""
completedUnread={false}
>
<div>
<button
type="button"
onClick={(event) => event.stopPropagation()}
>
More
</button>
{createPortal(<button type="button">Rename</button>, portalRoot)}
</div>
</SessionDetailsTooltip>
</I18nProvider>,
);
});
const row = container.firstElementChild;
const action = container.querySelector('button');
const menuItem = portalRoot.querySelector('button');
act(() => {
row?.dispatchEvent(new Event('pointerover', { bubbles: true }));
vi.advanceTimersByTime(300);
});
expect(document.querySelector('[role="dialog"]')).not.toBeNull();
act(() => {
action?.dispatchEvent(new Event('pointerdown', { bubbles: true }));
action?.click();
vi.advanceTimersByTime(100);
});
expect(document.querySelector('[role="dialog"]')).toBeNull();
act(() => {
menuItem?.dispatchEvent(new Event('pointerover', { bubbles: true }));
vi.advanceTimersByTime(300);
});
expect(document.querySelector('[role="dialog"]')).toBeNull();
act(() => root.unmount());
});
it('copies the complete session ID from the pointer-only panel', async () => {
vi.useFakeTimers();
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
});
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(
<I18nProvider language="en">
<SessionDetailsTooltip
session={{
sessionId: 'complete-session-id',
workspaceCwd: '/work/qwen-code',
}}
label="Session"
time=""
completedUnread={false}
>
<button type="button">Session</button>
</SessionDetailsTooltip>
</I18nProvider>,
);
});
const trigger = container.querySelector<HTMLButtonElement>('button');
await act(async () => {
trigger?.focus();
});
expect(document.querySelector('[role="dialog"]')).toBeNull();
await openDetails(container);
const copy = document.querySelector<HTMLButtonElement>(
'button[aria-label="Copy session ID"]',
);
expect(copy?.tabIndex).toBe(-1);
expect(document.activeElement).toBe(trigger);
expect(document.querySelector('[role="dialog"]')).not.toBeNull();
await act(async () => {
copy?.click();
await Promise.resolve();
});
expect(writeText).toHaveBeenCalledWith('complete-session-id');
expect(copy?.querySelector('.lucide-check')).not.toBeNull();
expect(document.querySelector('[aria-live="polite"]')?.className).toBe(
'sr-only',
);
act(() => vi.advanceTimersByTime(2000));
expect(copy?.querySelector('.lucide-copy')).not.toBeNull();
act(() => root.unmount());
});
it('keeps only the latest copy result', async () => {
vi.useFakeTimers();
const first = deferred<void>();
const second = deferred<void>();
const writeText = vi
.fn()
.mockReturnValueOnce(first.promise)
.mockReturnValueOnce(second.promise);
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
});
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(
<I18nProvider language="en">
<SessionDetailsTooltip
session={{ sessionId: 'session-id', workspaceCwd: '/work/repo' }}
label="Session"
time=""
completedUnread={false}
>
<button type="button">Session</button>
</SessionDetailsTooltip>
</I18nProvider>,
);
});
await openDetails(container);
const copy = document.querySelector<HTMLButtonElement>(
'button[aria-label="Copy session ID"]',
);
await act(async () => {
copy?.click();
copy?.click();
second.resolve(undefined);
await second.promise;
});
expect(copy?.querySelector('.lucide-check')).not.toBeNull();
await act(async () => {
first.reject(new Error('stale failure'));
await first.promise.catch(() => undefined);
});
expect(copy?.querySelector('.lucide-check')).not.toBeNull();
act(() => root.unmount());
});
it('ignores a pending copy result after the details close', async () => {
vi.useFakeTimers();
const pending = deferred<void>();
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText: vi.fn().mockReturnValue(pending.promise) },
});
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(
<I18nProvider language="en">
<SessionDetailsTooltip
session={{ sessionId: 'session-id', workspaceCwd: '/work/repo' }}
label="Session"
time=""
completedUnread={false}
>
<button type="button">Session</button>
</SessionDetailsTooltip>
</I18nProvider>,
);
});
await openDetails(container);
const trigger = container.querySelector<HTMLButtonElement>('button');
const copy = document.querySelector<HTMLButtonElement>(
'button[aria-label="Copy session ID"]',
);
await act(async () => {
copy?.click();
trigger?.click();
});
expect(document.querySelector('[role="dialog"]')).toBeNull();
await act(async () => {
pending.resolve(undefined);
await pending.promise;
});
expect(document.querySelector('[role="dialog"]')).toBeNull();
act(() => root.unmount());
});
it('reports clipboard failures', async () => {
vi.useFakeTimers();
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(
<I18nProvider language="en">
<SessionDetailsTooltip
session={{ sessionId: 'session-id', workspaceCwd: '/work/repo' }}
label="Session"
time=""
completedUnread={false}
>
<button type="button">Session</button>
</SessionDetailsTooltip>
</I18nProvider>,
);
});
await openDetails(container);
await act(async () => {
document
.querySelector<HTMLButtonElement>(
'button[aria-label="Copy session ID"]',
)
?.click();
});
expect(document.body.textContent).toContain('Failed to copy session ID');
act(() => root.unmount());
});
});

View file

@ -0,0 +1,205 @@
import { useEffect, useRef, useState, type ReactElement } from 'react';
import type { DaemonSessionSummary } from '@qwen-code/sdk/daemon';
import {
CheckIcon,
CopyIcon,
FolderClosedIcon,
GitBranchIcon,
RadioTowerIcon,
} from 'lucide-react';
import { useI18n } from '../../i18n';
import { workspaceBasename } from '../../utils/workspace';
import { Popover, PopoverAnchor, PopoverContent } from '../ui/popover';
import styles from './WebShellSidebar.module.css';
import { resolveSessionDetailsCollisionBoundary } from './sessionDetailsCollisionBoundary';
interface SessionDetailsTooltipProps {
session: DaemonSessionSummary;
label: string;
time: string;
completedUnread: boolean;
children: ReactElement;
}
export function SessionDetailsTooltip({
session,
label,
time,
completedUnread,
children,
}: SessionDetailsTooltipProps) {
const { t } = useI18n();
const [open, setOpen] = useState(false);
const [copyStatus, setCopyStatus] = useState<'idle' | 'copied' | 'failed'>(
'idle',
);
const copyAttemptRef = useRef(0);
const copyResetTimerRef = useRef<number | undefined>(undefined);
const openTimerRef = useRef<number | undefined>(undefined);
const closeTimerRef = useRef<number | undefined>(undefined);
const anchorRef = useRef<HTMLDivElement | null>(null);
const collisionBoundary = open
? resolveSessionDetailsCollisionBoundary(
anchorRef.current?.closest<HTMLElement>('aside') ?? null,
)
: null;
const folderPath = session.workspaceCwd;
const folderName = workspaceBasename(folderPath);
const branch = session.worktree?.branch ?? session.branch?.name;
const status = session.hasActivePrompt
? t('sidebar.running')
: completedUnread
? t('sidebar.completedUnread')
: t('sidebar.clients', { count: session.clientCount ?? 0 });
useEffect(() => {
return () => {
window.clearTimeout(openTimerRef.current);
window.clearTimeout(closeTimerRef.current);
window.clearTimeout(copyResetTimerRef.current);
copyAttemptRef.current += 1;
};
}, []);
useEffect(() => {
copyAttemptRef.current += 1;
window.clearTimeout(copyResetTimerRef.current);
setCopyStatus('idle');
}, [session.sessionId]);
const cancelClose = () => window.clearTimeout(closeTimerRef.current);
const openAfterDelay = () => {
cancelClose();
if (open) return;
window.clearTimeout(openTimerRef.current);
openTimerRef.current = window.setTimeout(() => setOpen(true), 300);
};
const close = () => {
window.clearTimeout(openTimerRef.current);
cancelClose();
setOpen(false);
copyAttemptRef.current += 1;
window.clearTimeout(copyResetTimerRef.current);
setCopyStatus('idle');
};
const closeAfterDelay = () => {
window.clearTimeout(openTimerRef.current);
cancelClose();
closeTimerRef.current = window.setTimeout(close, 100);
};
const handleOpenChange = (nextOpen: boolean) => {
if (nextOpen) setOpen(true);
else close();
};
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverAnchor
ref={anchorRef}
asChild
onPointerEnter={(event) => {
if (event.currentTarget.contains(event.target as Node)) {
openAfterDelay();
}
}}
onPointerLeave={closeAfterDelay}
onPointerDownCapture={close}
onClick={() => handleOpenChange(false)}
>
{children}
</PopoverAnchor>
<PopoverContent
side="right"
align="start"
sideOffset={0}
collisionBoundary={collisionBoundary ?? undefined}
collisionPadding={8}
updatePositionStrategy="always"
showArrow
role="dialog"
aria-label={label}
onOpenAutoFocus={(event) => event.preventDefault()}
onPointerEnter={cancelClose}
onPointerLeave={closeAfterDelay}
className={styles.sessionDetailsTooltip}
>
<div className={styles.sessionDetailsHeader}>
<span className={styles.sessionDetailsTitle} title={label}>
{label}
</span>
{time && <span className={styles.sessionDetailsTime}>{time}</span>}
</div>
<div className={styles.sessionDetailsRow}>
<FolderClosedIcon aria-hidden="true" />
<span title={folderPath}>{folderName}</span>
</div>
{branch && (
<div className={styles.sessionDetailsRow}>
<GitBranchIcon aria-hidden="true" />
<span title={branch}>{branch}</span>
</div>
)}
<div className={styles.sessionDetailsRow}>
<RadioTowerIcon aria-hidden="true" />
<span>{status}</span>
</div>
<div className={styles.sessionDetailsIdRow}>
<span title={session.sessionId}>{session.sessionId}</span>
<button
type="button"
tabIndex={-1}
className={styles.sessionDetailsCopyButton}
aria-label={t('sidebar.copySessionId')}
title={t('sidebar.copySessionId')}
onClick={() => {
const copyAttempt = ++copyAttemptRef.current;
if (
typeof navigator === 'undefined' ||
!navigator.clipboard?.writeText
) {
setCopyStatus('failed');
return;
}
void navigator.clipboard
.writeText(session.sessionId)
.then(() => {
if (copyAttemptRef.current === copyAttempt) {
setCopyStatus('copied');
window.clearTimeout(copyResetTimerRef.current);
copyResetTimerRef.current = window.setTimeout(() => {
if (copyAttemptRef.current === copyAttempt) {
setCopyStatus('idle');
}
}, 2000);
}
})
.catch(() => {
if (copyAttemptRef.current === copyAttempt) {
setCopyStatus('failed');
}
});
}}
>
{copyStatus === 'copied' ? (
<CheckIcon aria-hidden="true" />
) : (
<CopyIcon aria-hidden="true" />
)}
</button>
<span
className={
copyStatus === 'copied' ? 'sr-only' : styles.sessionDetailsCopied
}
aria-live="polite"
>
{copyStatus === 'copied'
? t('sidebar.sessionIdCopied')
: copyStatus === 'failed'
? t('sidebar.copySessionIdFailed')
: ''}
</span>
</div>
</PopoverContent>
</Popover>
);
}

View file

@ -0,0 +1,81 @@
// @vitest-environment jsdom
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { describe, expect, it } from 'vitest';
import { I18nProvider } from '../../i18n';
import { SessionGroupSection } from './SessionGroupSection';
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
describe('SessionGroupSection', () => {
it('shows five sessions and resets Show all after collapsing', () => {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const render = (expanded: boolean) => {
act(() => {
root.render(
<I18nProvider language="en">
<SessionGroupSection
id="group"
label="Group"
count={6}
expanded={expanded}
onToggle={() => {}}
>
{Array.from({ length: 6 }, (_, index) => (
<div key={index}>Session {index + 1}</div>
))}
</SessionGroupSection>
</I18nProvider>,
);
});
};
render(true);
expect(container.textContent).toContain('Session 5');
expect(container.textContent).not.toContain('Session 6');
const showAll = Array.from(container.querySelectorAll('button')).find(
(button) => button.textContent === 'Show all',
);
act(() => showAll?.click());
expect(container.textContent).toContain('Session 6');
render(false);
render(true);
expect(container.textContent).not.toContain('Session 6');
act(() => root.unmount());
container.remove();
});
it('shows every session when preview limiting is disabled', () => {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(
<I18nProvider language="en">
<SessionGroupSection
id="group"
label="Group"
count={6}
expanded
limitSessions={false}
onToggle={() => {}}
>
{Array.from({ length: 6 }, (_, index) => (
<div key={index}>Session {index + 1}</div>
))}
</SessionGroupSection>
</I18nProvider>,
);
});
expect(container.textContent).toContain('Session 6');
expect(container.textContent).not.toContain('Show all');
act(() => root.unmount());
container.remove();
});
});

View file

@ -1,4 +1,10 @@
import type { CSSProperties, ReactNode } from 'react';
import {
Children,
useEffect,
useState,
type CSSProperties,
type ReactNode,
} from 'react';
import type { DaemonSessionGroupColor } from '@qwen-code/sdk/daemon';
import {
ChevronDownIcon,
@ -6,6 +12,8 @@ import {
PencilIcon,
Trash2Icon,
} from 'lucide-react';
import { SIDEBAR_SESSION_PREVIEW_LIMIT } from '../../constants/sessions';
import { useI18n } from '../../i18n';
import styles from './WebShellSidebar.module.css';
export interface SessionGroupSectionProps {
@ -21,6 +29,7 @@ export interface SessionGroupSectionProps {
renameLabel?: string;
deleteLabel?: string;
actionsDisabled?: boolean;
limitSessions?: boolean;
}
export function SessionGroupSection({
@ -35,7 +44,14 @@ export function SessionGroupSection({
renameLabel,
deleteLabel,
actionsDisabled,
limitSessions = true,
}: SessionGroupSectionProps) {
const { t } = useI18n();
const [showAll, setShowAll] = useState(false);
const items = Children.toArray(children);
useEffect(() => {
if (!expanded) setShowAll(false);
}, [expanded]);
const colorClass = color?.startsWith('#')
? styles.groupColorCustom
: color
@ -95,7 +111,24 @@ export function SessionGroupSection({
</div>
)}
</div>
{expanded && <div className={styles.sessionGroupList}>{children}</div>}
{expanded && (
<div className={styles.sessionGroupList}>
{!limitSessions || showAll
? items
: items.slice(0, SIDEBAR_SESSION_PREVIEW_LIMIT)}
{limitSessions &&
!showAll &&
items.length > SIDEBAR_SESSION_PREVIEW_LIMIT && (
<button
type="button"
className={styles.showAllSessions}
onClick={() => setShowAll(true)}
>
{t('sidebar.showAllSessions')}
</button>
)}
</div>
)}
</section>
);
}

View file

@ -83,7 +83,23 @@
flex: 0 0 auto;
flex-direction: column;
gap: 2px;
padding: 12px 0;
padding-bottom: 4px;
}
.newTaskNav {
display: flex;
width: 100%;
flex: 0 0 auto;
padding: 12px 0 2px;
border-bottom: 1px solid transparent;
}
.newTaskNavScrolled {
border-bottom-color: color-mix(
in srgb,
var(--sidebar-border) 55%,
transparent
);
}
.newChatButton:disabled {
@ -127,6 +143,7 @@
}
.navIcon {
position: relative;
width: 24px;
height: 24px;
flex: 0 0 24px;
@ -386,7 +403,17 @@
flex-direction: column;
gap: 8px;
margin-right: -12px;
overflow: hidden;
padding-right: 12px;
/* End the scroll port above the footer overlay so rows can never sit
under it (padding inside the scroll port would not: the port itself
still reaches the footer and rows parked at its bottom edge stay
unhoverable). */
margin-bottom: 40px;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
touch-action: pan-y;
}
.sectionTitle {
@ -435,6 +462,7 @@
flex: 0 0 auto;
align-items: center;
gap: 2px;
margin-left: auto;
margin-right: 8px;
}
@ -477,20 +505,12 @@
.sessionText {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sessionBadgeIcon {
display: inline;
vertical-align: -1px;
margin-right: 3px;
color: var(--color-accent-fg, #8b5cf6);
flex-shrink: 0;
}
.projectName {
flex: 0 1 auto;
text-overflow: ellipsis;
}
.projectIconButton:last-child {
@ -507,18 +527,11 @@
}
.sessionList {
flex: 1 1 auto;
min-height: 0;
flex: 0 0 auto;
display: flex;
flex-direction: column;
gap: 4px;
padding-right: 12px;
padding-bottom: 44px;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
touch-action: pan-y;
overflow: visible;
}
.sessionGroupSection {
@ -643,18 +656,56 @@
gap: 2px;
}
.showAllSessions {
align-self: flex-start;
margin-left: 26px;
padding: 2px 0;
border: 0;
background: transparent;
color: var(--muted-foreground);
font-size: 14px;
line-height: 22px;
cursor: pointer;
}
.showAllSessions:hover {
color: var(--sidebar-foreground);
}
.showAllSessions:focus-visible {
color: var(--sidebar-foreground);
outline: 2px solid var(--sidebar-ring);
outline-offset: 2px;
}
.sessionRow {
position: relative;
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 6px;
gap: 2px;
padding: 2px 0 2px 26px;
border-radius: 8px;
color: color-mix(in srgb, var(--sidebar-foreground) 68%, transparent);
cursor: pointer;
}
.collapsedSessionPopover {
overscroll-behavior: contain;
width: min(
var(--collapsed-session-popover-width, 260px),
var(--radix-popover-content-available-width, 420px)
);
max-width: var(--radix-popover-content-available-width, 420px);
max-height: min(70vh, var(--radix-popover-content-available-height, 70vh));
display: flex;
flex-direction: column;
gap: 0;
padding: 8px;
overflow-y: auto;
touch-action: pan-y;
}
.currentSession {
background: var(--sidebar-accent);
color: var(--sidebar-accent-foreground);
@ -665,121 +716,96 @@
pointer-events: none;
}
.floatingTooltip {
position: fixed;
z-index: 1000;
max-width: 320px;
min-width: 220px;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--popover);
color: var(--popover-foreground);
box-shadow:
0 2px 4px -2px rgb(0 0 0 / 10%),
0 4px 6px -1px rgb(0 0 0 / 10%);
font-size: 12px;
font-weight: 400;
line-height: 18px;
overflow-wrap: anywhere;
pointer-events: auto;
transform: translateY(-50%);
}
.floatingTooltip::before {
position: absolute;
top: 0;
bottom: 0;
left: -12px;
width: 12px;
content: '';
}
.tooltipContent {
.sessionDetailsTooltip {
width: min(360px, var(--radix-popover-content-available-width, 360px));
min-width: min(280px, var(--radix-popover-content-available-width, 280px));
max-width: var(--radix-popover-content-available-width, 360px);
display: flex;
flex-direction: column;
gap: 8px;
align-items: stretch;
gap: 10px;
padding: 12px 14px;
border: 1px solid var(--border);
box-shadow: none;
font-size: 12px;
line-height: 20px;
}
.tooltipTitle {
display: -webkit-box;
.sessionDetailsHeader,
.sessionDetailsRow {
min-width: 0;
display: flex;
align-items: center;
gap: 10px;
}
.sessionDetailsHeader {
justify-content: space-between;
gap: 16px;
}
.sessionDetailsTitle {
min-width: 0;
overflow: hidden;
color: var(--popover-foreground);
font-size: 13px;
font-weight: 500;
line-height: 20px;
overflow-wrap: anywhere;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
line-clamp: 3;
font-size: 14px;
font-weight: 400;
text-overflow: ellipsis;
white-space: nowrap;
}
.tooltipTags {
display: flex;
flex-wrap: wrap;
gap: 6px;
.sessionDetailsTime {
flex: 0 0 auto;
color: var(--muted-foreground);
}
.tooltipTag {
.sessionDetailsRow {
color: var(--popover-foreground);
}
.sessionDetailsRow svg {
width: 14px;
height: 14px;
flex: 0 0 14px;
color: var(--muted-foreground);
stroke-width: 1.6;
}
.sessionDetailsRow span {
min-width: 0;
height: 20px;
display: inline-flex;
align-items: center;
padding: 0 6px;
border-radius: 999px;
background: var(--secondary);
color: var(--secondary-foreground);
font-size: 11px;
font-weight: 500;
line-height: 18px;
}
.tooltipTagRunning {
background: color-mix(in srgb, var(--agent-blue-500) 16%, transparent);
color: var(--agent-blue-500);
}
.tooltipTagNew {
background: color-mix(in srgb, var(--agent-blue-500) 12%, transparent);
color: var(--agent-blue-500);
}
.sessionDetailsContent {
width: min(16rem, var(--radix-dropdown-menu-content-available-width, 16rem));
max-width: var(--radix-dropdown-menu-content-available-width, 16rem);
max-height: var(--radix-dropdown-menu-content-available-height);
overflow: hidden auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sessionDetailsIdRow {
display: flex;
min-width: 0;
display: flex;
align-items: center;
gap: 6px;
}
.sessionDetailsId {
.sessionDetailsIdRow > span:first-child {
min-width: 0;
overflow: hidden;
color: var(--muted-foreground);
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
font-weight: 400;
line-height: 18px;
}
.sessionDetailsCopyButton {
width: 22px;
height: 22px;
flex: 0 0 22px;
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--muted-foreground);
cursor: pointer;
}
.sessionDetailsCopyButton:hover,
@ -790,24 +816,53 @@
}
.sessionDetailsCopyButton svg {
width: 14px;
height: 14px;
width: 13px;
height: 13px;
}
.sessionDetailsCopied {
flex: 0 0 auto;
color: var(--muted-foreground);
font-size: 12px;
line-height: 18px;
}
.sessionText {
position: relative;
min-width: 0;
flex: 1 1 auto;
margin-right: 18px;
margin-right: 4px;
overflow: hidden;
color: currentColor;
font-size: 14px;
line-height: 22px;
}
/* The fade hints at a clipped tail; only overflowing titles clip, and the
hover measurement marks them via the overflow attribute. */
.sessionText[data-web-shell-title-overflow] {
mask-image: linear-gradient(to right, #000 calc(100% - 10px), transparent);
-webkit-mask-image: linear-gradient(
to right,
#000 calc(100% - 10px),
transparent
);
}
.sessionTextInner {
display: inline-block;
min-width: max-content;
}
.sessionRow:hover .sessionTextInner {
animation: sessionTitleScroll var(--session-title-scroll-duration, 0s) 1s
linear forwards;
}
.sessionRow:focus-within .sessionTextInner {
animation: sessionTitleScroll var(--session-title-scroll-duration, 0s) 0.3s
linear forwards;
}
.sessionStatusSlot {
position: absolute;
top: 50%;
@ -821,15 +876,35 @@
}
.sessionStatusDot {
color: var(--agent-blue-500);
width: 6px;
height: 6px;
border-radius: 999px;
background: var(--agent-blue-500);
box-shadow: 0 0 0 3px
color-mix(in srgb, var(--agent-blue-500) 14%, transparent);
background: currentColor;
box-shadow: 0 0 0 3px color-mix(in srgb, currentColor 14%, transparent);
animation: sidebarPulse 1.6s ease-in-out infinite;
}
.collapsedSessionStatusDot {
position: absolute;
top: 1px;
right: 1px;
width: 6px;
height: 6px;
border-radius: 999px;
background: currentColor;
color: var(--agent-blue-500);
animation: sidebarPulse 1.6s ease-in-out infinite;
}
.collapsedSessionStatusApproval {
color: color-mix(in srgb, var(--success-color) 95%, var(--foreground));
}
.collapsedSessionStatusQuestion {
color: color-mix(in srgb, var(--agent-blue-500) 95%, var(--foreground));
}
.sessionTitlePin {
width: 18px;
height: 18px;
@ -896,39 +971,48 @@
}
.sessionRow:hover .sessionMetaSlot,
.sessionRow:focus-within .sessionMetaSlot,
.sessionMetaSlot:has(.sessionActionButton:focus-visible),
.sessionMetaSlot:has([data-state='open']) {
min-width: 86px;
min-width: var(--session-actions-width, 78px);
}
.sessionTime {
color: var(--muted-foreground);
font-size: 12px;
line-height: 18px;
white-space: nowrap;
padding-right: 10px;
.sessionGitIcon {
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--color-accent-fg, #8b5cf6);
}
.sessionGitIcon svg {
width: 14px;
height: 14px;
stroke-width: 1.6;
}
.sessionLoading {
margin-right: 10px;
width: 14px;
height: 14px;
width: 12px;
height: 12px;
display: inline-flex;
border: 2px solid color-mix(in srgb, var(--agent-blue-300) 24%, transparent);
border-top-color: var(--agent-blue-300);
border: 1.5px solid
color-mix(in srgb, var(--muted-foreground) 28%, transparent);
border-top-color: var(--muted-foreground);
border-radius: 999px;
animation: sidebarSpin 0.8s linear infinite;
}
.sessionRow:hover:not(.runningSession) .sessionTime,
.sessionRow:focus-within:not(.runningSession) .sessionTime,
.sessionRow:hover:not(.runningSession) .sessionGitIcon,
.sessionRow:focus-within:not(.runningSession) .sessionGitIcon,
.sessionRow:hover:not(.runningSession) .sessionAttention,
.sessionRow:focus-within:not(.runningSession) .sessionAttention,
.sessionMetaSlot:hover .sessionTime,
.sessionMetaSlot:hover .sessionGitIcon,
.sessionMetaSlot:hover .sessionLoading,
.sessionMetaSlot:hover .sessionAttention,
.sessionMetaSlot:has(.sessionActionButton:focus-visible) .sessionTime,
.sessionMetaSlot:has([data-state='open']) .sessionTime,
.sessionMetaSlot:has(.sessionActionButton:focus-visible) .sessionGitIcon,
.sessionMetaSlot:has([data-state='open']) .sessionGitIcon,
.sessionMetaSlot:has(.sessionActionButton:focus-visible) .sessionLoading,
.sessionMetaSlot:has([data-state='open']) .sessionLoading,
.sessionMetaSlot:has(.sessionActionButton:focus-visible) .sessionAttention,
@ -942,7 +1026,7 @@
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 2px;
gap: 0;
opacity: 0;
}
@ -960,18 +1044,29 @@
}
}
@keyframes sessionTitleScroll {
to {
transform: translateX(calc(var(--session-title-scroll-distance, 0px) * -1));
}
}
@media (prefers-reduced-motion: reduce) {
.sessionRow:hover .sessionTextInner,
.sessionRow:focus-within .sessionTextInner {
animation: none;
}
}
@keyframes sidebarPulse {
0%,
100% {
box-shadow: 0 0 0 2px
color-mix(in srgb, var(--agent-blue-500) 16%, transparent);
box-shadow: 0 0 0 2px color-mix(in srgb, currentColor 16%, transparent);
opacity: 0.72;
transform: scale(0.9);
}
50% {
box-shadow: 0 0 0 6px
color-mix(in srgb, var(--agent-blue-500) 0%, transparent);
box-shadow: 0 0 0 6px color-mix(in srgb, currentColor 0%, transparent);
opacity: 1;
transform: scale(1);
}
@ -993,6 +1088,7 @@
.sessionActionButton {
width: 26px;
height: 26px;
flex: 0 0 26px;
border-radius: 7px;
}
@ -1209,6 +1305,10 @@
text-align: left;
}
.notice {
padding: 4px 8px 4px 26px;
}
.retry {
width: 100%;
border-radius: 8px;
@ -1447,7 +1547,19 @@
.collapsed .body {
align-items: center;
width: 100%;
gap: 2px;
margin-right: 0;
padding-right: 0;
margin-bottom: 220px;
}
.collapsed .newTaskNav {
justify-content: center;
}
.collapsed .primaryNav {
align-items: center;
padding-bottom: 0;
}
.collapsed .sessionList {
@ -1455,7 +1567,6 @@
width: 100%;
margin-right: 0;
padding-right: 0;
padding-bottom: 220px;
}
.collapsed .footer {

View file

@ -23,6 +23,7 @@ const {
unarchiveSessionsData,
deleteSessionsData,
updateSessionOrganization,
updateSessionMetadata,
exportSession,
exportArchivedSession,
sessionActions,
@ -61,6 +62,7 @@ const {
errors: [],
});
const updateSessionOrganization = vi.fn().mockResolvedValue({});
const updateSessionMetadata = vi.fn().mockResolvedValue({});
const exportSession = vi.fn();
const active = makeSessions();
const archived = makeSessions();
@ -134,6 +136,7 @@ const {
archiveSessionsData,
unarchiveSessionsData,
exportArchivedSession,
updateSessionMetadata,
})),
},
refreshCapabilities: vi.fn(),
@ -152,6 +155,7 @@ const {
unarchiveSessionsData,
deleteSessionsData,
updateSessionOrganization,
updateSessionMetadata,
exportSession,
exportArchivedSession,
sessionActions,
@ -339,6 +343,7 @@ const capabilities = {
'workspace_runtime_removal',
'session_archive',
'workspace_qualified_rest_core',
'workspace_session_metadata',
'session_source_metadata',
],
workspaces: [
@ -377,6 +382,7 @@ function renderSidebar(
onOpenGoals?: () => void;
onOpenAddWorkspace?: () => void;
onNewSession?: (workspaceCwd?: string) => boolean;
onLoadSession?: (sessionId: string, workspaceCwd?: string) => void;
workspaces?: DaemonWorkspaceCapability[];
lockedWorkspaceCwd?: string;
lockedWorkspace?: {
@ -415,7 +421,7 @@ function renderSidebar(
onOpenSessions={() => {}}
onOpenSplitView={() => {}}
onNewSession={overrides.onNewSession ?? (() => false)}
onLoadSession={() => {}}
onLoadSession={overrides.onLoadSession ?? (() => {})}
onError={overrides.onError ?? (() => {})}
selectedWorkspaceCwd={overrides.selectedWorkspaceCwd}
onSelectWorkspace={overrides.onSelectWorkspace}
@ -458,18 +464,6 @@ function setInputValue(input: HTMLInputElement, value: string): void {
input.dispatchEvent(new Event('input', { bubbles: true }));
}
async function expandWorkspace(name: string): Promise<void> {
const button = Array.from(
container.querySelectorAll<HTMLButtonElement>('button'),
).find((candidate) => candidate.textContent?.includes(name));
expect(button).toBeDefined();
await act(async () => {
click(button!);
await Promise.resolve();
await Promise.resolve();
});
}
async function ensureWorkspaceExpanded(name: string): Promise<void> {
const button = Array.from(
container.querySelectorAll<HTMLButtonElement>('button'),
@ -481,9 +475,16 @@ async function ensureWorkspaceExpanded(name: string): Promise<void> {
await Promise.resolve();
await Promise.resolve();
});
} else {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
}
const expandWorkspace = ensureWorkspaceExpanded;
function archiveButtonFor(label: string): HTMLButtonElement | undefined {
return Array.from(
container.querySelectorAll<HTMLButtonElement>(
@ -663,13 +664,9 @@ function useWorkspaceSessionCatalog(
options?: { archiveState?: string; group?: string },
) => Promise<DaemonSessionSummary[]>,
): {
workspaceChannelTypes: ReturnType<typeof vi.fn>;
workspaceChannels: ReturnType<typeof vi.fn>;
channelCatalogCwds: string[];
} {
const workspaceChannelTypes = vi.fn().mockResolvedValue([]);
const workspaceChannels = vi
.fn()
.mockResolvedValue({ revision: '0', instances: {} });
const channelCatalogCwds: string[] = [];
workspace.client.workspaceByCwd.mockImplementation((cwd: string) => ({
listWorkspaceSessions: (options?: {
archiveState?: string;
@ -679,16 +676,23 @@ function useWorkspaceSessionCatalog(
return resolve(cwd, options);
},
listSessionGroups: vi.fn().mockResolvedValue({ groups: [] }),
workspaceChannelTypes,
workspaceChannels,
workspaceChannelTypes: vi.fn().mockImplementation(() => {
channelCatalogCwds.push(cwd);
return Promise.resolve([]);
}),
workspaceChannels: vi.fn().mockImplementation(() => {
channelCatalogCwds.push(cwd);
return Promise.resolve({ revision: '0', instances: {} });
}),
archiveSessionsData,
unarchiveSessionsData,
deleteSessionsData,
updateSessionOrganization,
updateSessionMetadata,
exportSession,
exportArchivedSession,
}));
return { workspaceChannelTypes, workspaceChannels };
return { channelCatalogCwds };
}
function openRemoval(cwd: string): void {
@ -746,6 +750,8 @@ beforeEach(() => {
});
updateSessionOrganization.mockReset();
updateSessionOrganization.mockResolvedValue({});
updateSessionMetadata.mockReset();
updateSessionMetadata.mockResolvedValue({});
exportSession.mockReset();
sessionActions.renameSession.mockReset();
sessionActions.renameSession.mockResolvedValue(undefined);
@ -761,6 +767,7 @@ beforeEach(() => {
unarchiveSessionsData,
deleteSessionsData,
updateSessionOrganization,
updateSessionMetadata,
exportSession,
exportArchivedSession,
}));
@ -1054,7 +1061,7 @@ describe('WebShellSidebar workspace removal', () => {
expect(primaryArchive).not.toHaveBeenCalled();
});
it('allows only the current locked-secondary session to rename', async () => {
it('shows rename for current and non-current locked-secondary sessions', async () => {
connection.sessionId = 'locked-current';
connection.workspaceCwd = '/tmp/other';
connection.capabilities = {
@ -1102,33 +1109,9 @@ describe('WebShellSidebar workspace removal', () => {
expect(inlineSessionAction('Locked current', 'Archive')?.disabled).toBe(
true,
);
expect(inlineSessionAction('Locked other', 'Rename')?.disabled).toBe(true);
const currentRow = Array.from(
container.querySelectorAll<HTMLElement>('[role="button"]'),
).find((row) => row.textContent?.includes('Locked current'));
await act(async () => {
currentRow?.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }));
await Promise.resolve();
});
const input = container.querySelector<HTMLInputElement>('input');
expect(input).not.toBeNull();
await act(async () => {
setInputValue(input!, 'Renamed locked current');
input!
.closest('form')
?.dispatchEvent(
new Event('submit', { bubbles: true, cancelable: true }),
);
await sessionActions.renameSession.mock.results.at(-1)?.value;
});
expect(sessionActions.renameSession).toHaveBeenCalledWith(
'Renamed locked current',
);
expect(renameSessionCatalog).toHaveBeenCalledWith(
'/tmp/other',
'locked-current',
'Renamed locked current',
expect(inlineSessionAction('Locked other', 'Rename')?.disabled).toBe(false);
expect(inlineSessionAction('Locked current', 'Rename')?.disabled).toBe(
false,
);
});
@ -1616,12 +1599,12 @@ describe('WebShellSidebar workspace removal', () => {
expect(normalItems).toEqual(
expect.arrayContaining([
'Archive',
'Details',
'Group',
'Export conversation record',
'Delete',
]),
);
expect(normalItems).not.toContain('Details');
expect(inlineSessionAction('Configured pinned', 'Unpin')).toBeDefined();
expect(inlineSessionAction('Configured pinned', 'Archive')).toBeUndefined();
@ -1634,7 +1617,6 @@ describe('WebShellSidebar workspace removal', () => {
const archivedItems = await openSessionMenuItems('Configured archived');
expect(archivedItems).toEqual(
expect.arrayContaining([
'Details',
'Export conversation record',
'Restore',
'Delete',
@ -1642,6 +1624,7 @@ describe('WebShellSidebar workspace removal', () => {
);
expect(archivedItems).not.toContain('Pin');
expect(archivedItems).not.toContain('Group');
expect(archivedItems).not.toContain('Details');
});
it('renders locked normal, pinned, and archived rows action-free when no items are configured', async () => {
@ -2023,19 +2006,7 @@ describe('WebShellSidebar workspace removal', () => {
'primary-archived-controlled',
);
const staleTrigger = sessionAction('Stale archived controlled');
expect(staleTrigger).toBeDefined();
await act(async () => {
click(staleTrigger!);
await Promise.resolve();
});
const staleMenuItems = Array.from(
document.body.querySelectorAll<HTMLElement>('[role="menuitem"]'),
).map((item) => item.textContent);
expect(staleMenuItems).toContain('Details');
expect(staleMenuItems).not.toContain('Export');
expect(staleMenuItems).not.toContain('Restore');
expect(staleMenuItems).not.toContain('Delete');
expect(sessionAction('Stale archived controlled')).toBeUndefined();
expect(deleteSessionsData).not.toHaveBeenCalled();
expect(exportArchivedSession).not.toHaveBeenCalled();
});
@ -2124,7 +2095,7 @@ describe('WebShellSidebar workspace removal', () => {
});
await expandWorkspace('project');
expect(inlineSessionAction('Legacy primary', 'Rename')).toBeDefined();
expect(inlineSessionAction('Legacy primary', 'Rename')).toBeUndefined();
expect(inlineSessionAction('Legacy primary', 'Pin')).toBeDefined();
expect(archiveButtonFor('Legacy primary')?.disabled).toBe(false);
expect(
@ -2134,6 +2105,11 @@ describe('WebShellSidebar workspace removal', () => {
false,
);
expect(sessionAction('Legacy primary')).toBeDefined();
expect(
inlineSessionAction('Legacy primary', 'Pin')
?.closest<HTMLElement>('[class*="sessionMetaSlot"]')
?.style.getPropertyValue('--session-actions-width'),
).toBe('130px');
});
it('fails closed for an explicit primary cwd that disappears from the catalog', async () => {
@ -2325,6 +2301,264 @@ describe('WebShellSidebar workspace removal', () => {
expect(sessionActions.renameSession).toHaveBeenCalledTimes(2);
});
it('renames a non-current session through its workspace route', async () => {
const onLoadSession = vi.fn();
connection.sessionId = 'current-session';
active.sessions.push(
{
sessionId: 'current-session',
workspaceCwd: '/tmp/project',
displayName: 'Current session',
},
{
sessionId: 'other-session',
workspaceCwd: '/tmp/project',
displayName: 'Other session',
},
);
vi.spyOn(HTMLInputElement.prototype, 'focus').mockImplementation(() => {});
renderSidebar({
sessionActions: { items: ['rename'], inlineItems: ['rename'] },
onLoadSession,
});
await expandWorkspace('project');
const rename = inlineSessionAction('Other session', 'Rename');
expect(rename?.disabled).toBe(false);
await act(async () => {
click(rename!);
await Promise.resolve();
});
const input = container.querySelector<HTMLInputElement>('input');
expect(input).not.toBeNull();
await act(async () => {
setInputValue(input!, 'Renamed other session');
input!.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }),
);
input!
.closest('form')
?.dispatchEvent(
new Event('submit', { bubbles: true, cancelable: true }),
);
await updateSessionMetadata.mock.results.at(-1)?.value;
});
expect(workspace.client.workspaceByCwd).toHaveBeenCalledWith(
'/tmp/project',
);
expect(updateSessionMetadata).toHaveBeenCalledWith('other-session', {
displayName: 'Renamed other session',
});
expect(sessionActions.renameSession).not.toHaveBeenCalled();
expect(onLoadSession).not.toHaveBeenCalled();
});
it('keeps the next rename editor when an earlier rename settles late', async () => {
connection.sessionId = 'current-session';
active.sessions.push(
{
sessionId: 'current-session',
workspaceCwd: '/tmp/project',
displayName: 'Current session',
},
{
sessionId: 'first-session',
workspaceCwd: '/tmp/project',
displayName: 'First session',
},
{
sessionId: 'second-session',
workspaceCwd: '/tmp/project',
displayName: 'Second session',
},
);
let resolveFirstRename!: (value: unknown) => void;
updateSessionMetadata
.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveFirstRename = resolve;
}),
)
.mockResolvedValue({});
renderSidebar({
sessionActions: { items: ['rename'], inlineItems: ['rename'] },
});
await expandWorkspace('project');
await act(async () => {
click(inlineSessionAction('First session', 'Rename')!);
await Promise.resolve();
});
const firstInput = container.querySelector<HTMLInputElement>('input');
expect(firstInput).not.toBeNull();
await act(async () => {
setInputValue(firstInput!, 'First renamed');
firstInput!
.closest('form')
?.dispatchEvent(
new Event('submit', { bubbles: true, cancelable: true }),
);
await Promise.resolve();
});
expect(updateSessionMetadata).toHaveBeenCalledTimes(1);
await act(async () => {
click(inlineSessionAction('Second session', 'Rename')!);
await Promise.resolve();
});
const secondInput = container.querySelector<HTMLInputElement>('input');
expect(secondInput).not.toBeNull();
await act(async () => {
setInputValue(secondInput!, 'Second renamed');
await Promise.resolve();
});
await act(async () => {
resolveFirstRename({ displayName: 'First renamed' });
await updateSessionMetadata.mock.results[0]?.value;
await Promise.resolve();
});
const survivor = container.querySelector<HTMLInputElement>('input');
expect(survivor).not.toBeNull();
expect(survivor!.value).toBe('Second renamed');
});
it('does not reopen a rename editor while that session is saving', async () => {
active.sessions.push({
sessionId: 'other-session',
workspaceCwd: '/tmp/project',
displayName: 'Other session',
});
let resolveRename!: (value: unknown) => void;
updateSessionMetadata.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveRename = resolve;
}),
);
renderSidebar({
sessionActions: { items: ['rename'], inlineItems: ['rename'] },
});
await expandWorkspace('project');
await act(async () => {
click(inlineSessionAction('Other session', 'Rename')!);
await Promise.resolve();
});
const input = container.querySelector<HTMLInputElement>('input');
expect(input).not.toBeNull();
act(() => {
input!
.closest('form')
?.dispatchEvent(
new Event('submit', { bubbles: true, cancelable: true }),
);
input!.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
);
});
act(() => click(inlineSessionAction('Other session', 'Rename')!));
expect(container.querySelector<HTMLInputElement>('input')).toBeNull();
await act(async () => {
resolveRename({ displayName: 'Other session' });
await updateSessionMetadata.mock.results[0]?.value;
await Promise.resolve();
});
act(() => click(inlineSessionAction('Other session', 'Rename')!));
expect(container.querySelector<HTMLInputElement>('input')).not.toBeNull();
});
it('keeps a persisted workspace collapse when sections remount', async () => {
connection.workspaceCwd = '/tmp/other';
connection.sessionId = 'secondary-session';
renderSidebar();
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
// The current session lives in the secondary workspace, so it was
// auto-expanded once.
const secondaryHeader = () =>
Array.from(
container.querySelectorAll<HTMLButtonElement>('button[aria-expanded]'),
).find((button) => button.textContent?.includes('other'));
expect(secondaryHeader()?.getAttribute('aria-expanded')).toBe('true');
// The user collapses it; the choice is persisted.
await act(async () => {
click(secondaryHeader()!);
await Promise.resolve();
});
expect(secondaryHeader()?.getAttribute('aria-expanded')).toBe('false');
expect(
window.localStorage.getItem(
'qwen.web-shell.sidebar.workspace-expanded:secondary',
),
).toBe('false');
// Toggle the Projects header off/on: every workspace section remounts,
// replaying the stale one-shot auto-expand.
const projectsToggle = () =>
Array.from(
container.querySelectorAll<HTMLButtonElement>('button[aria-expanded]'),
).find(
(button) =>
button.textContent?.includes('Project') &&
!button.textContent?.includes('other'),
);
expect(projectsToggle()).toBeDefined();
await act(async () => {
click(projectsToggle()!);
await Promise.resolve();
});
await act(async () => {
click(projectsToggle()!);
await Promise.resolve();
await Promise.resolve();
});
expect(secondaryHeader()?.getAttribute('aria-expanded')).toBe('false');
});
it('hides non-current rename when workspace metadata is unsupported', async () => {
connection.sessionId = 'current-session';
connection.capabilities = {
...capabilities,
features: capabilities.features.filter(
(feature) => feature !== 'workspace_session_metadata',
),
};
active.sessions.push(
{
sessionId: 'current-session',
workspaceCwd: '/tmp/project',
displayName: 'Current session',
},
{
sessionId: 'other-session',
workspaceCwd: '/tmp/project',
displayName: 'Other session',
},
);
renderSidebar({
sessionActions: { items: ['rename'], inlineItems: ['rename'] },
});
await expandWorkspace('project');
expect(inlineSessionAction('Current session', 'Rename')).toBeDefined();
expect(inlineSessionAction('Other session', 'Rename')).toBeUndefined();
});
it('honors a missing rename item for double-click editing', async () => {
connection.sessionId = 'current-primary';
active.sessions.push({
@ -2347,7 +2581,7 @@ describe('WebShellSidebar workspace removal', () => {
expect(sessionActions.renameSession).not.toHaveBeenCalled();
});
it('allows pin on an unlocked secondary workspace but keeps destructive actions restricted', async () => {
it('shows trusted secondary workspace actions', async () => {
connection.capabilities = {
...capabilities,
features: [...capabilities.features, 'session_organization'],
@ -2373,6 +2607,8 @@ describe('WebShellSidebar workspace removal', () => {
expect(inlineSessionAction('Pinned secondary', 'Unpin')).toBeDefined();
expect(inlineSessionAction('Pinned secondary', 'Delete')).toBeUndefined();
expect(inlineSessionAction('Pinned secondary', 'Rename')).toBeUndefined();
const items = await openSessionMenuItems('Pinned secondary');
expect(items).toEqual(expect.arrayContaining(['Rename', 'Delete']));
renderSidebar({ lockedWorkspaceCwd: '/tmp/other' });
await act(async () => {
@ -2383,7 +2619,7 @@ describe('WebShellSidebar workspace removal', () => {
expect(sessionAction('Pinned secondary')).toBeDefined();
});
it('allows organization but keeps destructive actions conservative for unlocked secondary sessions', async () => {
it('allows trusted unlocked secondary session actions', async () => {
connection.capabilities = {
...capabilities,
features: [
@ -2462,15 +2698,18 @@ describe('WebShellSidebar workspace removal', () => {
expect(archiveButtonFor('Unlocked normal')?.disabled).toBe(false);
expect(inlineSessionAction('Unlocked normal', 'Pin')).toBeDefined();
expect(inlineSessionAction('Unlocked normal', 'Delete')).toBeUndefined();
const activeItems = await openSessionMenuItems('Unlocked normal');
expect(activeItems).toEqual(expect.arrayContaining(['Rename', 'Delete']));
expect(archiveButtonFor('Unlocked pinned')?.disabled).toBe(false);
expect(inlineSessionAction('Unlocked pinned', 'Unpin')).toBeDefined();
const archivedItems = await openSessionMenuItems('Unlocked archived');
expect(archivedItems).toEqual([
'Details',
'Rename',
'Export conversation record',
'Restore',
'Delete',
]);
expect(deleteSessionsData).not.toHaveBeenCalled();
expect(updateSessionOrganization).not.toHaveBeenCalled();
@ -2575,7 +2814,7 @@ describe('WebShellSidebar workspace removal', () => {
});
expect(render).toHaveBeenLastCalledWith(
expect.objectContaining({ id: 'secondary', cwd: '/tmp/other' }),
{ expanded: false },
{ expanded: true },
);
expect(
container.querySelector('[data-testid="custom-workspace"]')?.textContent,
@ -2598,7 +2837,7 @@ describe('WebShellSidebar workspace removal', () => {
});
expect(render).toHaveBeenLastCalledWith(
expect.objectContaining({ id: 'secondary', cwd: '/tmp/other' }),
{ expanded: true },
{ expanded: false },
);
});
@ -3010,8 +3249,7 @@ describe('WebShellSidebar non-primary archive', () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(container.textContent).toContain('Untrusted primary active');
// Active read-only rows have no action menu; archived rows retain the
// separately configured Details-only menu below.
// Active read-only rows have no action menu.
expect(sessionAction('Untrusted primary active')).toBeUndefined();
expect(
inlineSessionAction('Untrusted primary active', 'Pin'),
@ -4048,6 +4286,64 @@ describe('WebShellSidebar session source switch', () => {
});
describe('WebShellSidebar session list notices', () => {
it('limits the flat session preview to five until Show all is selected', async () => {
active.sessions = Array.from({ length: 6 }, (_, index) => ({
sessionId: `session-${index + 1}`,
displayName: `Preview session ${index + 1}`,
workspaceCwd: '/tmp/project',
}));
renderSidebar();
await ensureWorkspaceExpanded('project');
expect(container.textContent).toContain('Preview session 5');
expect(container.textContent).not.toContain('Preview session 6');
const showAll = Array.from(
container.querySelectorAll<HTMLButtonElement>('button'),
).find((button) => button.textContent === 'Show all');
expect(showAll).toBeDefined();
act(() => click(showAll!));
expect(container.textContent).toContain('Preview session 6');
});
it('keeps an edited session visible when the preview order changes', async () => {
active.sessions = Array.from({ length: 6 }, (_, index) => ({
sessionId: `session-${index + 1}`,
displayName: `Preview session ${index + 1}`,
workspaceCwd: '/tmp/project',
}));
renderSidebar({
sessionActions: { items: ['rename'], inlineItems: ['rename'] },
});
await ensureWorkspaceExpanded('project');
await act(async () => {
click(inlineSessionAction('Preview session 5', 'Rename')!);
await Promise.resolve();
});
const input = container.querySelector<HTMLInputElement>('input');
expect(input).not.toBeNull();
await act(async () => {
setInputValue(input!, 'Renaming five');
active.sessions = [
{
sessionId: 'session-fresh',
displayName: 'A fresh session',
workspaceCwd: '/tmp/project',
},
...active.sessions,
];
renderSidebar({
sessionActions: { items: ['rename'], inlineItems: ['rename'] },
});
await Promise.resolve();
});
expect(container.querySelector<HTMLInputElement>('input')?.value).toBe(
'Renaming five',
);
});
it('keeps a settled filtered-empty view while a refresh is in flight', async () => {
active.sessions = [
{
@ -4067,7 +4363,7 @@ describe('WebShellSidebar session list notices', () => {
await Promise.resolve();
});
expect(container.textContent).toContain('No matching sessions.');
expect(container.textContent).toContain('No sessions.');
expect(container.textContent).not.toContain('Loading sessions...');
});
@ -4090,7 +4386,7 @@ describe('WebShellSidebar session list notices', () => {
await Promise.resolve();
});
expect(container.textContent).toContain('No matching sessions.');
expect(container.textContent).toContain('No sessions.');
expect(container.textContent).not.toContain('Failed to load sessions');
});
@ -4115,7 +4411,7 @@ describe('WebShellSidebar session list notices', () => {
renderSidebar();
await ensureWorkspaceExpanded('project');
expect(container.textContent).toContain('No matching sessions.');
expect(container.textContent).toContain('No sessions.');
expect(container.textContent).not.toContain('Loading sessions...');
});
@ -4126,7 +4422,7 @@ describe('WebShellSidebar session list notices', () => {
renderSidebar();
await ensureWorkspaceExpanded('project');
expect(container.textContent).toContain('No matching sessions.');
expect(container.textContent).toContain('No sessions.');
expect(container.textContent).not.toContain('Failed to load sessions');
});
@ -4209,13 +4505,73 @@ describe('WebShellSidebar Live group', () => {
await act(async () => {
await new Promise<void>((resolve) => setTimeout(resolve, 0));
});
expect(channelCatalog.workspaceChannelTypes).not.toHaveBeenCalled();
expect(channelCatalog.workspaceChannels).not.toHaveBeenCalled();
// Sections start expanded, so non-live workspaces legitimately load
// their own channel catalog; the live section must never join in.
expect(channelCatalog.channelCatalogCwds).not.toContain(liveWorkspace.cwd);
expect(container.textContent).not.toContain('Other channels');
expect(container.textContent).toContain('Voice check');
});
});
describe('WebShellSidebar pinned live session rows', () => {
async function settle(): Promise<void> {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
it('renders a pinned session that is live in a trusted workspace once', async () => {
connection.capabilities = {
...capabilities,
features: [...capabilities.features, 'session_organization'],
};
workspace.capabilities = connection.capabilities;
const liveWorkspace: DaemonWorkspaceCapability = {
id: 'live',
cwd: '/tmp/live',
displayName: 'Conversations',
primary: false,
trusted: true,
kind: 'live',
};
useWorkspaceSessionCatalog(async (cwd, options) => {
if (cwd !== liveWorkspace.cwd) return [];
const session = {
sessionId: 'live-pinned',
workspaceCwd: cwd,
displayName: 'Live pinned',
isPinned: true,
};
if (options?.group === 'pinned') return [session];
if (options?.archiveState === 'active') return [session];
return [];
});
renderSidebar({
workspaces: [...capabilities.workspaces, liveWorkspace],
sessionActions: { items: ['rename'], inlineItems: ['rename'] },
});
await settle();
await expandWorkspace('Live');
const rows = Array.from(
container.querySelectorAll<HTMLElement>('[class*="sessionRow"]'),
).filter((row) => row.textContent?.includes('Live pinned'));
expect(rows).toHaveLength(1);
const rename = inlineSessionAction('Live pinned', 'Rename');
expect(rename?.disabled).toBe(false);
await act(async () => {
click(rename!);
await Promise.resolve();
});
expect(
container.querySelectorAll('form[class*="renameForm"]'),
).toHaveLength(1);
});
});
describe('WebShellSidebar archived session export', () => {
const exportResult = {
content: '<p>exported</p>',
@ -4272,22 +4628,12 @@ describe('WebShellSidebar archived session export', () => {
renderSidebar();
await expandArchived();
const trigger = sessionAction('Archived untrusted');
expect(trigger).toBeDefined();
await act(async () => {
click(trigger!);
await Promise.resolve();
});
const archivedItems = Array.from(
document.body.querySelectorAll<HTMLElement>('[role="menuitem"]'),
).map((item) => item.textContent);
expect(archivedItems).toEqual(['Details']);
expect(sessionAction('Archived untrusted')).toBeUndefined();
expect(deleteSessionsData).not.toHaveBeenCalled();
expect(unarchiveSessionsData).not.toHaveBeenCalled();
});
it('keeps an untrusted primary archived row action-free except configured Details', async () => {
it('keeps an untrusted primary archived row action-free', async () => {
connection.capabilities = {
...capabilities,
features: [
@ -4325,8 +4671,7 @@ describe('WebShellSidebar archived session export', () => {
});
await expandArchived();
const items = await openSessionMenuItems('Untrusted primary archived');
expect(items).toEqual(['Details']);
expect(sessionAction('Untrusted primary archived')).toBeUndefined();
expect(archived.unarchiveSession).not.toHaveBeenCalled();
expect(archived.deleteSession).not.toHaveBeenCalled();
expect(exportArchivedSession).not.toHaveBeenCalled();

View file

@ -8,6 +8,7 @@
align-items: center;
width: 100%;
border-radius: 6px;
cursor: pointer;
transition: background 0.1s;
}
@ -33,6 +34,7 @@
.headerDisabled {
opacity: 0.5;
cursor: default;
}
.headerDisabled .header {
@ -76,16 +78,15 @@
border-radius: 4px;
}
/* Live git chip in the folder header icon-only (the chip's `compact` form): a
status dot on the branch icon conveys dirty / conflict / in-progress at a
glance, while the branch name + ahead/behind live in the hover tooltip.
Rendered as a sibling of the header button (buttons can't nest), kept snug
after the folder name. The chip is a fixed 28px box, so the pill grows only to
carry the spare width to the hover actions on the right. */
/* Live git chip in the folder header. */
.gitPill {
--git-branch-icon-size: 12px;
--git-branch-badge-size: 5px;
--git-branch-badge-offset: -1px;
display: inline-flex;
flex: 1 1 auto;
min-width: 0;
flex: 0 0 24px;
width: 24px;
padding: 0;
border: none;
background: none;
@ -95,9 +96,14 @@
cursor: pointer;
}
/* With a chip present, let the pill not the folder name own the spare width,
so the icon sits right after the name and the hover actions stay pinned to the
right edge. Without a chip the header keeps growing as before. */
.gitPill [data-web-shell-git-branch] {
width: 24px;
max-width: 24px;
flex-basis: 24px;
padding: 0 5px;
}
/* Keep the icon directly after the folder name. */
.headerRow:has(.gitPill) .header {
flex-grow: 0;
}
@ -113,7 +119,7 @@
.empty {
padding: 4px 8px 4px 26px;
font-size: 13px;
font-size: 12px;
color: var(--muted-foreground);
}
@ -135,14 +141,40 @@
}
.sessionName {
position: relative;
min-width: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sessionTime {
flex: 0 0 auto;
font-size: 10px;
color: var(--muted-foreground);
.sessionName[data-web-shell-title-overflow] {
mask-image: linear-gradient(to right, #000 calc(100% - 10px), transparent);
-webkit-mask-image: linear-gradient(
to right,
#000 calc(100% - 10px),
transparent
);
}
.sessionNameInner {
display: inline-block;
min-width: max-content;
}
.sessionItemReadOnly:hover .sessionNameInner {
animation: sessionTitleScroll var(--session-title-scroll-duration, 0s) 1s
linear forwards;
}
@keyframes sessionTitleScroll {
to {
transform: translateX(calc(var(--session-title-scroll-distance, 0px) * -1));
}
}
@media (prefers-reduced-motion: reduce) {
.sessionItemReadOnly:hover .sessionNameInner {
animation: none;
}
}

View file

@ -82,6 +82,9 @@ function makeClient(): DaemonClient {
const { I18nProvider } = await import('../../i18n');
const { WorkspaceSection } = await import('./WorkspaceSection');
const { readWorkspaceExpanded, writeWorkspaceExpanded } = await import(
'./workspaceExpansion'
);
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
if (!globalThis.PointerEvent) {
@ -145,7 +148,6 @@ function renderSection(
sourceType={overrides.sourceType}
channelGroupingEnabled={overrides.channelGroupingEnabled}
ungroupedLabel="Ungrouped"
formatTime={() => ''}
renderSession={(session: DaemonSessionSummary): ReactNode => (
<div key={session.sessionId}>{session.displayName}</div>
)}
@ -167,6 +169,7 @@ function gitChip(): HTMLElement | null {
}
beforeEach(() => {
window.localStorage.clear();
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
@ -190,6 +193,7 @@ beforeEach(() => {
afterEach(() => {
act(() => root.unmount());
container.remove();
vi.useRealTimers();
vi.restoreAllMocks();
});
@ -206,7 +210,7 @@ describe('WorkspaceSection label', () => {
expect(container.textContent).not.toContain('project');
});
it('shows the complete read-only session name in a native tooltip', async () => {
it('shows read-only session details from row hover', async () => {
const listWorkspaceSessionsPage = vi.fn().mockResolvedValue({
sessions: [
{
@ -233,7 +237,33 @@ describe('WorkspaceSection label', () => {
expect(
container.querySelector('[title="A very long session name"]'),
).not.toBeNull();
).toBeNull();
const row = container.querySelector<HTMLElement>('[role="note"]');
if (!row) throw new Error('read-only row was not rendered');
expect(row.tabIndex).toBe(-1);
vi.useFakeTimers();
act(() => {
row.dispatchEvent(new Event('pointerover', { bubbles: true }));
vi.advanceTimersByTime(300);
});
const tooltip = document.querySelector('[role="dialog"]');
expect(tooltip?.textContent).toContain('A very long session name');
expect(tooltip?.textContent).toContain('danger');
expect(tooltip?.querySelector('[title="/tmp/danger"]')).not.toBeNull();
vi.useRealTimers();
});
it('restores and writes the workspace expansion preference', () => {
writeWorkspaceExpanded(trustedWorkspace.id, false);
renderSection();
const toggle = container.querySelector<HTMLButtonElement>(
'button[aria-expanded]',
);
expect(toggle?.getAttribute('aria-expanded')).toBe('false');
act(() => toggle?.click());
expect(toggle?.getAttribute('aria-expanded')).toBe('true');
expect(readWorkspaceExpanded(trustedWorkspace.id)).toBe(true);
});
it('does not render sessions loaded for the previous source', async () => {
@ -671,6 +701,39 @@ describe('WorkspaceSection label', () => {
});
describe('WorkspaceSection session loading', () => {
it('shows five sessions and resets Show all after the workspace closes', async () => {
const sessions = Array.from({ length: 6 }, (_, index) => ({
sessionId: `session-${index + 1}`,
displayName: `Session ${index + 1}`,
workspaceCwd: trustedWorkspace.cwd,
}));
const listWorkspaceSessionsPage = vi.fn().mockResolvedValue({ sessions });
const client = {
workspaceByCwd: vi.fn(() => ({
workspaceGit,
listWorkspaceSessionsPage,
listSessionGroups: vi.fn().mockResolvedValue({ groups: [] }),
})),
} as unknown as DaemonClient;
renderSection({ client, expanded: true });
await flush();
expect(container.textContent).toContain('Session 5');
expect(container.textContent).not.toContain('Session 6');
const showAll = Array.from(container.querySelectorAll('button')).find(
(button) => button.textContent === 'Show all',
);
act(() => showAll?.click());
expect(container.textContent).toContain('Session 6');
renderSection({ client, expanded: false });
await flush();
renderSection({ client, expanded: true });
await flush();
expect(container.textContent).not.toContain('Session 6');
});
it('refreshes the catalog when an expanded workspace loses trust', async () => {
const listWorkspaceSessionsPage = vi
.fn()

View file

@ -19,15 +19,27 @@ import { FolderClosedIcon, FolderOpenIcon } from 'lucide-react';
import { GitBranchIndicator } from '../GitBranchIndicator';
import { BranchPickerPopover } from '../BranchPickerPopover';
import { useI18n } from '../../i18n';
import { SESSION_LIST_PAGE_SIZE } from '../../constants/sessions';
import { formatRelativeTime } from '../../utils/formatRelativeTime';
import {
SESSION_LIST_PAGE_SIZE,
SIDEBAR_SESSION_PREVIEW_LIMIT,
} from '../../constants/sessions';
import {
readWorkspaceCollapsedGroupIds,
writeWorkspaceCollapsedGroupIds,
} from './collapsedSessionSections';
import {
hasWorkspaceExpansionPreference,
readWorkspaceExpanded,
writeWorkspaceExpanded,
} from './workspaceExpansion';
import { workspaceLabel } from '../../utils/workspace';
import { SessionGroupSection } from './SessionGroupSection';
import { SessionDetailsTooltip } from './SessionDetailsTooltip';
import { measureSessionTitleScroll } from './sessionTitleScroll';
import { groupSessionsByChannelType } from './channelSessionGroups';
import styles from './WorkspaceSection.module.css';
import sidebarStyles from './WebShellSidebar.module.css';
import { useSessionCatalogQuery } from '../../session-catalog/session-catalog-hooks';
import type { SessionCatalogQuery } from '../../session-catalog/session-catalog-store';
@ -76,7 +88,6 @@ interface WorkspaceSectionProps {
sourceType?: string;
channelGroupingEnabled?: boolean;
ungroupedLabel: string;
formatTime: (iso: string) => string;
searchQuery?: string;
expanded?: boolean;
autoExpandKey?: string;
@ -89,6 +100,7 @@ interface WorkspaceSectionProps {
* instead of a bespoke, feature-poor row.
*/
renderSession: (session: DaemonSessionSummary) => ReactNode;
showSessionDetails?: boolean;
headerActions?: (visible: boolean) => ReactNode;
onRenameGroup?: (group: DaemonSessionGroup, workspaceCwd: string) => void;
onDeleteGroup?: (group: DaemonSessionGroup, workspaceCwd: string) => void;
@ -96,6 +108,7 @@ interface WorkspaceSectionProps {
deleteGroupLabel?: string;
groupActionsDisabled?: boolean;
excludePinned?: boolean;
limitSessions?: boolean;
/**
* Open the working-tree Changes dialog for this workspace. When provided, the
* folder header shows a live git chip (branch + dirty/ahead-behind state) that
@ -119,13 +132,13 @@ export function WorkspaceSection({
sourceType,
channelGroupingEnabled = false,
ungroupedLabel,
formatTime,
searchQuery = '',
expanded: controlledExpanded,
autoExpandKey,
onExpandedChange,
renderSessions = true,
renderSession,
showSessionDetails = true,
headerActions,
onRenameGroup,
onDeleteGroup,
@ -133,6 +146,7 @@ export function WorkspaceSection({
deleteGroupLabel,
groupActionsDisabled,
excludePinned = false,
limitSessions = true,
onOpenGitDiff,
onOpenCommit,
}: WorkspaceSectionProps) {
@ -141,11 +155,14 @@ export function WorkspaceSection({
catalog: DaemonChannelTypeCatalog;
snapshot: DaemonChannelsSnapshot;
}>();
const [internalExpanded, setInternalExpanded] = useState(false);
const [internalExpanded, setInternalExpanded] = useState(() =>
readWorkspaceExpanded(workspace.id),
);
const [collapsedGroupIds, setCollapsedGroupIds] = useState<Set<string>>(() =>
readWorkspaceCollapsedGroupIds(workspace.id),
);
const [actionsVisible, setActionsVisible] = useState(false);
const [showAllSessions, setShowAllSessions] = useState(false);
const [gitStatus, setGitStatus] = useState<DaemonWorkspaceGitStatus>();
const [branchPickerOpen, setBranchPickerOpen] = useState(false);
const channelCatalogLoadRequestId = useRef(0);
@ -155,11 +172,19 @@ export function WorkspaceSection({
const disabled = workspace.primary && !workspace.trusted;
const searchActive = searchQuery.trim().length > 0;
// A workspace always starts collapsed, including the primary workspace.
// Uncontrolled workspace rows restore the user's last choice.
useEffect(() => {
if (controlledExpanded === undefined) setInternalExpanded(false);
if (controlledExpanded === undefined) {
setInternalExpanded(readWorkspaceExpanded(workspace.id));
}
}, [controlledExpanded, workspace.id]);
useEffect(() => {
// The five-row preview is scoped per source; reset the one-shot
// show-all when the section collapses or the source changes.
setShowAllSessions(false);
}, [expanded, sourceType]);
// The render site keys this component by workspace id, so an id change
// always remounts and the lazy useState initializer re-reads storage.
useEffect(() => {
@ -167,10 +192,14 @@ export function WorkspaceSection({
}, [collapsedGroupIds, workspace.id]);
useEffect(() => {
if (controlledExpanded === undefined && autoExpandKey) {
if (
controlledExpanded === undefined &&
autoExpandKey &&
!hasWorkspaceExpansionPreference(workspace.id)
) {
setInternalExpanded(true);
}
}, [autoExpandKey, controlledExpanded]);
}, [autoExpandKey, controlledExpanded, workspace.id]);
const sessionsEnabled = renderSessions && !disabled;
const sessionsVisible = expanded || Boolean(searchQuery.trim());
@ -374,6 +403,10 @@ export function WorkspaceSection({
);
});
}, [excludePinned, searchQuery, sessions]);
const directSessions =
searchActive || showAllSessions || !limitSessions
? visibleSessions
: visibleSessions.slice(0, SIDEBAR_SESSION_PREVIEW_LIMIT);
const groupedSessions = useMemo(() => {
if (!organizationEnabled || channelGroupingEnabled || groups.length === 0)
@ -407,10 +440,23 @@ export function WorkspaceSection({
[channelCatalog, channelGroupingEnabled, t, visibleSessions],
);
const toggleExpanded = () => {
if (disabled) return;
const nextExpanded = !expanded;
setInternalExpanded(nextExpanded);
if (controlledExpanded === undefined) {
writeWorkspaceExpanded(workspace.id, nextExpanded);
}
onExpandedChange?.(nextExpanded);
};
return (
<div className={styles.section}>
<div
className={cx(styles.headerRow, disabled && styles.headerDisabled)}
onClick={(event) => {
if (event.target === event.currentTarget) toggleExpanded();
}}
onMouseEnter={() => setActionsVisible(true)}
onMouseLeave={() => setActionsVisible(false)}
onFocus={() => setActionsVisible(true)}
@ -425,11 +471,7 @@ export function WorkspaceSection({
type="button"
disabled={disabled}
aria-expanded={expanded}
onClick={() => {
const nextExpanded = !expanded;
setInternalExpanded(nextExpanded);
onExpandedChange?.(nextExpanded);
}}
onClick={toggleExpanded}
>
{renderHeader ? (
renderHeader(expanded)
@ -501,6 +543,7 @@ export function WorkspaceSection({
key={group.id}
label={group.label}
count={group.sessions.length}
limitSessions={limitSessions && !searchActive}
expanded={!collapsedGroupIds.has(group.id)}
onToggle={() => {
setCollapsedGroupIds((current) => {
@ -520,9 +563,10 @@ export function WorkspaceSection({
{groupedSessions.sections.map(({ group, sessions }) => (
<SessionGroupSection
id={`group:${group.id}`}
key={group.id}
key={`${group.id}:${sourceType ?? ''}`}
label={group.name}
count={sessions.length}
limitSessions={limitSessions && !searchActive}
color={group.color}
expanded={!collapsedGroupIds.has(group.id)}
onToggle={() => {
@ -552,9 +596,11 @@ export function WorkspaceSection({
))}
{groupedSessions.ungrouped.length > 0 && (
<SessionGroupSection
key={`ungrouped:${sourceType ?? ''}`}
id="ungrouped"
label={ungroupedLabel}
count={groupedSessions.ungrouped.length}
limitSessions={limitSessions && !searchActive}
expanded={!collapsedGroupIds.has('ungrouped')}
onToggle={() => {
setCollapsedGroupIds((current) => {
@ -572,26 +618,56 @@ export function WorkspaceSection({
)}
</>
) : (
visibleSessions.map((session) => {
if (!readOnly) return renderSession(session);
const label = getSessionLabel(session);
const time = session.createdAt
? formatTime(session.createdAt)
: '';
return (
<div
key={session.sessionId}
className={styles.sessionItemReadOnly}
role="note"
aria-label={`${label}${time ? `, ${time}` : ''}. ${trustToOpenLabel}`}
>
<span className={styles.sessionName} title={label}>
{label}
</span>
{time && <span className={styles.sessionTime}>{time}</span>}
</div>
);
})
<>
{directSessions.map((session) => {
if (!readOnly) return renderSession(session);
const label = getSessionLabel(session);
const stamp = session.updatedAt || session.createdAt;
const row = (
<div
key={session.sessionId}
className={styles.sessionItemReadOnly}
role="note"
aria-label={`${label}. ${trustToOpenLabel}`}
onMouseEnter={(event) =>
measureSessionTitleScroll(event.currentTarget)
}
>
<span
className={styles.sessionName}
data-web-shell-session-title
>
<span className={styles.sessionNameInner}>{label}</span>
</span>
</div>
);
return showSessionDetails ? (
<SessionDetailsTooltip
key={session.sessionId}
session={session}
label={label}
time={stamp ? formatRelativeTime(stamp, t) : ''}
completedUnread={false}
>
{row}
</SessionDetailsTooltip>
) : (
row
);
})}
{limitSessions &&
!searchActive &&
!showAllSessions &&
visibleSessions.length > SIDEBAR_SESSION_PREVIEW_LIMIT && (
<button
type="button"
className={sidebarStyles.showAllSessions}
onClick={() => setShowAllSessions(true)}
>
{t('sidebar.showAllSessions')}
</button>
)}
</>
)}
</div>
)}

View file

@ -19,18 +19,15 @@ const DEFAULT_ITEMS: readonly WebShellSidebarSessionActionItem[] = ALL_ITEMS;
const DEFAULT_INLINE_ITEMS: readonly WebShellSidebarSessionInlineActionItem[] =
['pin', 'archive'];
/**
* Items that can never appear as inline buttons (no working handler).
* These always fall to the dropdown when present in `items`.
*/
/** Items that can never appear as inline buttons. */
const DROPDOWN_ONLY_ITEMS: readonly WebShellSidebarSessionActionItem[] = [
'details',
'group',
];
interface VisibilityResult {
inline: Set<WebShellSidebarSessionActionItem>;
dropdown: Set<WebShellSidebarSessionActionItem>;
hover: Set<WebShellSidebarSessionActionItem>;
showDropdownTrigger: boolean;
}
@ -48,12 +45,14 @@ function computeVisibility(
const inline = new Set<WebShellSidebarSessionActionItem>();
const dropdown = new Set<WebShellSidebarSessionActionItem>();
const hover = new Set<WebShellSidebarSessionActionItem>();
for (const item of ALL_ITEMS) {
if (!itemSet.has(item)) continue;
if (DROPDOWN_ONLY_ITEMS.includes(item)) {
// details/group can never be inline — always dropdown
if (item === 'details') {
hover.add(item);
} else if (DROPDOWN_ONLY_ITEMS.includes(item)) {
dropdown.add(item);
} else if (inlineSet.has(item as WebShellSidebarSessionInlineActionItem)) {
inline.add(item);
@ -65,26 +64,25 @@ function computeVisibility(
return {
inline,
dropdown,
hover,
showDropdownTrigger: dropdown.size > 0,
};
}
describe('session action visibility matrix', () => {
describe('defaults (no consumer config)', () => {
it('pin+archive inline, remaining items in dropdown', () => {
const { inline, dropdown, showDropdownTrigger } = computeVisibility(
DEFAULT_ITEMS,
DEFAULT_INLINE_ITEMS,
);
it('shows details on hover, pin+archive inline, and mutations in the dropdown', () => {
const { inline, dropdown, hover, showDropdownTrigger } =
computeVisibility(DEFAULT_ITEMS, DEFAULT_INLINE_ITEMS);
expect([...inline].sort()).toEqual(['archive', 'pin']);
expect([...dropdown].sort()).toEqual([
'delete',
'details',
'export',
'group',
'rename',
]);
expect([...hover]).toEqual(['details']);
expect(showDropdownTrigger).toBe(true);
});
});
@ -97,7 +95,9 @@ describe('session action visibility matrix', () => {
);
expect(inline.size).toBe(0);
expect([...dropdown].sort()).toEqual([...ALL_ITEMS].sort());
expect([...dropdown].sort()).toEqual(
ALL_ITEMS.filter((item) => item !== 'details').sort(),
);
expect(showDropdownTrigger).toBe(true);
});
@ -120,7 +120,6 @@ describe('session action visibility matrix', () => {
expect([...inline].sort()).toEqual(['delete']);
expect([...dropdown].sort()).toEqual([
'archive',
'details',
'export',
'group',
'pin',
@ -138,14 +137,13 @@ describe('session action visibility matrix', () => {
expect([...dropdown].sort()).toEqual(['pin']);
});
it('items: ["details", "group"] — both in dropdown, nothing inline', () => {
const { inline, dropdown, showDropdownTrigger } = computeVisibility(
['details', 'group'],
DEFAULT_INLINE_ITEMS,
);
it('items: ["details", "group"] — details stays on hover', () => {
const { inline, dropdown, hover, showDropdownTrigger } =
computeVisibility(['details', 'group'], DEFAULT_INLINE_ITEMS);
expect(inline.size).toBe(0);
expect([...dropdown].sort()).toEqual(['details', 'group']);
expect([...dropdown]).toEqual(['group']);
expect([...hover]).toEqual(['details']);
expect(showDropdownTrigger).toBe(true);
});

View file

@ -0,0 +1,22 @@
const TITLE_SCROLL_SPEED_PX_PER_S = 38;
/**
* Measures the title inside `row` and refreshes the hover-scroll CSS
* variables plus the overflow flag that gates the right-edge fade mask.
* Rows call this on every pointer entry so a renamed or resized label never
* animates with a stale distance.
*/
export function measureSessionTitleScroll(row: HTMLElement): void {
const title = row.querySelector<HTMLElement>(
'[data-web-shell-session-title]',
);
if (!title) return;
const label = title.firstElementChild;
const distance = Math.max(0, (label?.scrollWidth ?? 0) - title.clientWidth);
title.style.setProperty('--session-title-scroll-distance', `${distance}px`);
title.style.setProperty(
'--session-title-scroll-duration',
`${distance / TITLE_SCROLL_SPEED_PX_PER_S}s`,
);
title.toggleAttribute('data-web-shell-title-overflow', distance > 0);
}

View file

@ -0,0 +1,52 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it } from 'vitest';
import {
hasWorkspaceExpansionPreference,
migrateWorkspaceExpansionPreference,
readWorkspaceExpanded,
writeWorkspaceExpanded,
} from './workspaceExpansion';
describe('workspace expansion persistence', () => {
beforeEach(() => window.localStorage.clear());
it('defaults to expanded and restores the user choice', () => {
expect(readWorkspaceExpanded('workspace')).toBe(true);
expect(hasWorkspaceExpansionPreference('workspace')).toBe(false);
writeWorkspaceExpanded('workspace', false);
expect(readWorkspaceExpanded('workspace')).toBe(false);
expect(hasWorkspaceExpansionPreference('workspace')).toBe(true);
});
it('migrates a preference written under a provisional id', () => {
writeWorkspaceExpanded('primary:/tmp/connection', false);
migrateWorkspaceExpansionPreference(
'primary:/tmp/connection',
'primary:/tmp/primary',
);
expect(readWorkspaceExpanded('primary:/tmp/primary')).toBe(false);
expect(hasWorkspaceExpansionPreference('primary:/tmp/connection')).toBe(
false,
);
});
it('keeps an existing preference when ids converge', () => {
writeWorkspaceExpanded('primary:/tmp/connection', false);
writeWorkspaceExpanded('primary:/tmp/primary', true);
migrateWorkspaceExpansionPreference(
'primary:/tmp/connection',
'primary:/tmp/primary',
);
expect(readWorkspaceExpanded('primary:/tmp/primary')).toBe(true);
expect(hasWorkspaceExpansionPreference('primary:/tmp/connection')).toBe(
false,
);
});
});

View file

@ -0,0 +1,54 @@
const STORAGE_PREFIX = 'qwen.web-shell.sidebar.workspace-expanded:';
export function hasWorkspaceExpansionPreference(id: string): boolean {
if (typeof window === 'undefined') return false;
try {
return window.localStorage.getItem(`${STORAGE_PREFIX}${id}`) !== null;
} catch {
return false;
}
}
export function readWorkspaceExpanded(id: string): boolean {
if (typeof window === 'undefined') return true;
try {
return window.localStorage.getItem(`${STORAGE_PREFIX}${id}`) !== 'false';
} catch {
return true;
}
}
export function writeWorkspaceExpanded(id: string, expanded: boolean): void {
if (typeof window === 'undefined') return;
try {
window.localStorage.setItem(`${STORAGE_PREFIX}${id}`, String(expanded));
} catch {
// localStorage can be unavailable in private or embedded contexts.
}
}
// The primary expansion id is keyed by the primary workspace cwd, which is
// only provisional until the registered workspace list lands. Move a
// preference written under the provisional id so the choice survives.
export function migrateWorkspaceExpansionPreference(
previousId: string,
nextId: string,
): void {
if (typeof window === 'undefined' || previousId === nextId) return;
try {
const previousKey = `${STORAGE_PREFIX}${previousId}`;
const nextKey = `${STORAGE_PREFIX}${nextId}`;
const stored = window.localStorage.getItem(previousKey);
if (stored === null) return;
if (window.localStorage.getItem(nextKey) !== null) {
// The registered preference already won; drop the superseded
// provisional entry so it stops reading as a live preference.
window.localStorage.removeItem(previousKey);
return;
}
window.localStorage.setItem(nextKey, stored);
window.localStorage.removeItem(previousKey);
} catch {
// localStorage can be unavailable in private or embedded contexts.
}
}

View file

@ -23,11 +23,24 @@ const PopoverTrigger = React.forwardRef<
);
});
type PopoverContentProps = React.ComponentProps<
typeof PopoverPrimitive.Content
> & {
showArrow?: boolean;
};
const PopoverContent = React.forwardRef<
React.ComponentRef<typeof PopoverPrimitive.Content>,
React.ComponentProps<typeof PopoverPrimitive.Content>
PopoverContentProps
>(function PopoverContent(
{ className, align = 'center', sideOffset = 4, ...props },
{
className,
align = 'center',
sideOffset = 4,
showArrow = false,
children,
...props
},
ref,
) {
const portalRoot = useWebShellPortalRoot();
@ -39,11 +52,30 @@ const PopoverContent = React.forwardRef<
align={align}
sideOffset={sideOffset}
className={cn(
'z-[var(--web-shell-popover-z-index,1000)] flex w-72 origin-(--radix-popover-content-transform-origin) flex-col gap-2.5 rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
'z-[var(--web-shell-popover-z-index,1000)] flex w-72 origin-(--radix-popover-content-transform-origin) flex-col gap-2.5 rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 [--floating-arrow-offset:-1px] outline-hidden duration-100 data-[side=top]:[--floating-arrow-offset:0px] data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
className,
)}
{...props}
/>
>
{children}
{showArrow && (
<PopoverPrimitive.Arrow asChild width={12} height={6}>
<svg
style={{ transform: 'translateY(var(--floating-arrow-offset))' }}
aria-hidden="true"
>
<path className="fill-popover" d="M0 0h30L15 10Z" />
<path
className="fill-none stroke-border"
d="M.5 0 15 9.5 29.5 0"
strokeWidth={1}
strokeLinejoin="round"
vectorEffect="non-scaling-stroke"
/>
</svg>
</PopoverPrimitive.Arrow>
)}
</PopoverPrimitive.Content>
</PopoverPrimitive.Portal>
);
});

View file

@ -29,11 +29,6 @@ function TooltipTrigger({
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
// With TooltipPrimitive.Arrow present, Radix's offset middleware computes
// `mainAxis: sideOffset + arrowHeight`, so the arrow's 10px box already
// pushes the content out. The previous pseudo-element arrow took no layout
// space and was tuned against sideOffset 8; keeping 8 here would move every
// tooltip ~10px farther from its trigger.
function TooltipContent({
className,
sideOffset = 0,
@ -47,20 +42,32 @@ function TooltipContent({
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
'relative z-50 inline-flex w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin) items-center gap-1.5 rounded-md border border-border bg-popover px-3 py-1.5 text-xs text-popover-foreground has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-[state=instant-open]:animate-in data-[state=instant-open]:fade-in-0 data-[state=instant-open]:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
'relative z-50 inline-flex w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin) items-center gap-1.5 rounded-md border border-border bg-popover px-3 py-1.5 text-xs text-popover-foreground [--floating-arrow-offset:-1px] data-[side=top]:[--floating-arrow-offset:0px] has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-[state=instant-open]:animate-in data-[state=instant-open]:fade-in-0 data-[state=instant-open]:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
className,
)}
{...props}
>
{children}
{/* Radix computes the arrow offset from the trigger position, so the
tip keeps pointing at the trigger even after collision avoidance
shifts the content near a viewport edge a pseudo-element pinned
at the content's center cannot. */}
<TooltipPrimitive.Arrow
data-slot="tooltip-arrow"
className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] border-r border-b border-border bg-popover fill-popover"
/>
asChild
width={12}
height={6}
>
<svg
style={{ transform: 'translateY(var(--floating-arrow-offset))' }}
aria-hidden="true"
>
<path className="fill-popover" d="M0 0h30L15 10Z" />
<path
className="fill-none stroke-border"
d="M.5 0 15 9.5 29.5 0"
strokeWidth={1}
strokeLinejoin="round"
vectorEffect="non-scaling-stroke"
/>
</svg>
</TooltipPrimitive.Arrow>
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);

View file

@ -9,6 +9,7 @@
* retention limits cannot drift between the main and split views.
*/
export const SESSION_LIST_PAGE_SIZE = 1000;
export const SIDEBAR_SESSION_PREVIEW_LIMIT = 5;
export const SESSION_ORGANIZATION_FEATURE = 'session_organization';
export const SESSION_TRANSCRIPT_PAGINATION_FEATURE =
'session_transcript_pagination';

View file

@ -76,17 +76,13 @@ test('keeps long session details inside a constrained WebShell @smoke', async ({
const webShellRoot = page.locator('[data-web-shell-root]');
await page.getByRole('button', { name: 'Toggle menu' }).click();
const sessionTitle = page.getByTitle(longTitle);
const sessionTitle = webShellRoot.getByText(longTitle, { exact: true });
await expect(sessionTitle).toBeVisible();
const sessionRow = sessionTitle.locator('..');
await sessionRow.hover();
await sessionRow.getByRole('button', { name: 'More actions' }).click();
await page.getByRole('menuitem', { name: 'Details' }).hover();
const details = page.locator('[data-slot="dropdown-menu-sub-content"]');
await sessionTitle.hover();
const details = page.getByRole('dialog', { name: longTitle });
const title = details.getByTitle(longTitle);
const copyAction = details.getByRole('menuitem', {
const copyAction = details.getByRole('button', {
name: 'Copy session ID',
});
await expect(details).toBeVisible();
@ -103,6 +99,11 @@ test('keeps long session details inside a constrained WebShell @smoke', async ({
{ width: 520, height: 320 },
]) {
await page.setViewportSize(size);
// Close the details popover before re-hovering: at constrained sizes it
// can flip to cover its own anchor row and intercept the hover.
await page.mouse.move(0, 0);
await expect(details).toBeHidden();
await sessionTitle.hover();
await expect(details).toBeVisible();
await expectDetailsInsideRoot(webShellRoot, details);
await expect(copyAction).toBeVisible();
@ -113,16 +114,14 @@ test('keeps long session details inside a constrained WebShell @smoke', async ({
return {
clientHeight: element.clientHeight,
lineHeight: Number.parseFloat(style.lineHeight),
scrollHeight: element.scrollHeight,
clientWidth: element.clientWidth,
scrollWidth: element.scrollWidth,
};
});
expect(titleMetrics.clientHeight).toBeLessThanOrEqual(
titleMetrics.lineHeight * 3 + 1,
titleMetrics.lineHeight + 1,
);
expect(titleMetrics.clientHeight).toBeGreaterThanOrEqual(
titleMetrics.lineHeight * 3 - 1,
);
expect(titleMetrics.scrollHeight).toBeGreaterThan(titleMetrics.clientHeight);
expect(titleMetrics.scrollWidth).toBeGreaterThan(titleMetrics.clientWidth);
});
async function expectDetailsInsideRoot(

View file

@ -1240,7 +1240,6 @@ const EN: Messages = {
'sidebar.project': 'Project',
'sidebar.pinnedSessions': 'Pinned',
'sidebar.workspaceSelectLabel': 'Workspace',
'sidebar.details': 'Details',
'sidebar.copySessionId': 'Copy session ID',
'sidebar.copySessionIdFailed': 'Failed to copy session ID',
'sidebar.sessionIdCopied': 'Session ID copied',
@ -1314,13 +1313,12 @@ const EN: Messages = {
'sidebar.themeDark': 'Switch to dark theme',
'sidebar.collapse': 'Collapse',
'sidebar.expand': 'Expand',
'sidebar.showAllSessions': 'Show all',
'sidebar.collapseProject': 'Collapse project',
'sidebar.expandProject': 'Expand project',
'sidebar.search': 'Search sessions',
'sidebar.searchPlaceholder': 'Search sessions',
'sidebar.searchEmpty': 'No matching sessions.',
'sidebar.rename': 'Rename',
'sidebar.renameCurrentOnly': 'Only the current session can be renamed',
'sidebar.export': 'Export conversation record',
'sidebar.exportFailed': 'Failed to export session',
'sidebar.delete': 'Delete',
@ -4183,7 +4181,6 @@ const ZH: Messages = {
'sidebar.project': '项目',
'sidebar.pinnedSessions': '置顶',
'sidebar.workspaceSelectLabel': '工作区',
'sidebar.details': '详情',
'sidebar.copySessionId': '复制会话 ID',
'sidebar.copySessionIdFailed': '复制会话 ID 失败',
'sidebar.sessionIdCopied': '会话 ID 已复制',
@ -4251,13 +4248,12 @@ const ZH: Messages = {
'sidebar.themeDark': '切换到深色主题',
'sidebar.collapse': '收起',
'sidebar.expand': '展开',
'sidebar.showAllSessions': '展开显示',
'sidebar.collapseProject': '收起项目',
'sidebar.expandProject': '展开项目',
'sidebar.search': '搜索会话',
'sidebar.searchPlaceholder': '搜索会话',
'sidebar.searchEmpty': '没有匹配的会话。',
'sidebar.rename': '重命名',
'sidebar.renameCurrentOnly': '暂仅支持重命名当前会话',
'sidebar.export': '导出对话记录',
'sidebar.exportFailed': '导出会话失败',
'sidebar.delete': '删除',

View file

@ -8908,6 +8908,84 @@ describe('DaemonSessionProvider', () => {
]);
});
it('keeps an imperatively loaded session when controlled workspace props catch up', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response(undefined, { status: 204 })),
);
sdkMocks.sessions.push(
createMockSession({
sessionId: 'session-a',
workspaceCwd: '/work/a',
}),
);
let actions: DaemonSessionActions | undefined;
let connection: DaemonConnectionState | undefined;
function Harness() {
actions = useDaemonActions();
connection = useDaemonConnection();
return null;
}
await renderWithProvider(<Harness />, {
autoConnect: true,
sessionId: 'session-a',
workspaceCwd: '/work/a',
});
sdkMocks.MockDaemonSessionClient.load.mockClear();
sdkMocks.sessions.push(
createMockSession({
sessionId: 'session-b',
workspaceCwd: '/work/b',
}),
);
let loadSession: Promise<void> | undefined;
act(() => {
loadSession = requireActions(actions).loadSession('session-b', {
workspaceCwd: '/work/b',
});
});
await act(async () => {
await wait(5);
await flushPromises();
});
await act(async () => {
await loadSession;
await flushPromises();
});
expect(connection).toMatchObject({
status: 'connected',
sessionId: 'session-b',
workspaceCwd: '/work/b',
});
act(() => {
root?.render(
<DaemonSessionProvider
baseUrl="http://127.0.0.1:4170"
autoConnect={true}
sessionId="session-b"
workspaceCwd="/work/b"
>
<Harness />
</DaemonSessionProvider>,
);
});
await act(async () => {
await flushPromises();
});
expect(sdkMocks.MockDaemonSessionClient.load).toHaveBeenCalledOnce();
expect(connection).toMatchObject({
status: 'connected',
sessionId: 'session-b',
workspaceCwd: '/work/b',
missingSession: false,
});
});
it('clears transcript loading after replay before metadata finishes', async () => {
const providers = createDeferred<unknown>();
const commands =

View file

@ -742,6 +742,8 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) {
};
}, []);
const sessionEffectWorkspaceCwd = restoreWorkspaceCwd ?? workspaceCwd;
useEffect(() => {
if (!autoConnect) return undefined;
if (!workspaceClientRef.current && !resolvedBaseUrl) {
@ -2764,7 +2766,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) {
autoReconnect,
resolvedBaseUrl,
resolvedToken,
workspaceCwd,
sessionEffectWorkspaceCwd,
modelServiceId,
sessionScope,
maxQueued,