mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-21 06:35:07 +00:00
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:
parent
ba2d512497
commit
9a5b07c0b1
37 changed files with 4774 additions and 1922 deletions
41
docs/design/2026-08-13-web-shell-sidebar-session-details.md
Normal file
41
docs/design/2026-08-13-web-shell-sidebar-session-details.md
Normal 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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -406,6 +406,7 @@ describe('qwen serve — capabilities envelope', () => {
|
|||
'workspace_persisted_transcript',
|
||||
'workspace_session_export',
|
||||
'workspace_archived_session_export',
|
||||
'workspace_session_metadata',
|
||||
'voice_transcribe',
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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?: (
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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());
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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 {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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.
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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': '删除',
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue