mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
fix(vscode): unify session history and remove source switch (#11713)
* fix(vscode): unify session history and remove source switch Co-authored-by: Qwen-Coder <[email protected]> * fix(vscode): harden unified history after review Close stale history on host bootstrap and exclude marked Live coordinator sessions from ordinary history. Remove outdated source-isolation documentation. Co-authored-by: Qwen-Coder <[email protected]> * chore(vscode): name the Live source prefix producer in the history filter The filter's realtime_voice: literal duplicates LIVE_SESSION_SOURCE_PREFIX from packages/cli, which cannot be imported into the extension bundle. Name the producer so a future prefix change is caught by reading rather than by a silent regression that re-lists Live voice threads as ordinary rows. --------- Co-authored-by: Qwen-Coder <[email protected]>
This commit is contained in:
1 parent
12ffcb1f66
commit
df864bea6a
11 files changed
+271
-886
No files matched your search
@@ -1,22 +1,23 @@
|
||||
# VS Code session source switch E2E plan
|
||||
# Unified VS Code session history E2E plan
|
||||
|
||||
## Scenario
|
||||
|
||||
1. In one workspace, create one session in the VS Code Companion sidebar and one from the terminal or browser Web Shell.
|
||||
2. Open the sidebar's session-history dialog and confirm it initially shows the VS Code session.
|
||||
3. Switch to **Terminal / Web** and confirm the other session appears without rename or delete controls.
|
||||
4. Open that session, reload the extension host, and confirm it remains accessible without moving into the VS Code source list.
|
||||
5. Create a new session from the VS Code header and confirm it appears in the VS Code source list.
|
||||
2. Open the sidebar's history dialog and confirm both conversations appear together, with no source selector.
|
||||
3. Confirm both have rename and delete controls, except that deleting the current conversation remains disabled.
|
||||
4. Open the terminal conversation, verify its transcript and continue it, then reload the extension host and verify the same conversation restores without changing its source metadata.
|
||||
5. Create a new session from the VS Code header and confirm it appears in the same list with VS Code creation attribution.
|
||||
6. Page through older conversations, including a page containing only child/background sessions; verify Load more stays available and a failed request can be retried without losing visible rows.
|
||||
|
||||
## Expected result
|
||||
|
||||
- Each source view contains only its scoped catalog plus allowlisted pre-cutover sessions in the VS Code view.
|
||||
- Switching sources replaces the page; a late response from the previous source cannot overwrite it.
|
||||
- Opening a Terminal / Web session never rewrites its creator attribution, including after the sidebar re-bootstraps.
|
||||
- Ordinary conversations from the same workspace appear together, including unattributed legacy sessions without an allowlist.
|
||||
- Known child/background sessions stay out of the conversation list, and sparse pages remain navigable.
|
||||
- Opening an existing session never supplies replacement creator attribution, including after the sidebar re-bootstraps.
|
||||
- New VS Code sessions retain VS Code attribution.
|
||||
|
||||
## Automated coverage
|
||||
|
||||
The focused webview tests cover scoped requests, the legacy allowlist, stale responses, read-only foreign rows, re-bootstrap attribution, and new-session attribution. A real VS Code host remains a manual validation boundary.
|
||||
The focused tests cover unified catalog requests, mixed-source rows and actions, terminal-session selection, reload and new-session attribution, and pagination retries. A real VS Code host remains a manual validation boundary.
|
||||
|
||||
The global `qwen` CLI has no companion history dialog, so a standalone CLI dry run is not a faithful baseline for this UI scenario.
|
||||
@@ -1,26 +1,26 @@
|
||||
# VS Code session source switch
|
||||
# Unified VS Code session history
|
||||
|
||||
[中文版](./vscode-session-source-switch.zh-CN.md)
|
||||
|
||||
## Context
|
||||
|
||||
The daemon stores VS Code, terminal, and browser sessions for the same workspace. VS Code deliberately defaults to its own sessions, while the pre-cutover recovery path added in #11495 also restores only legacy session ids recorded by the extension. Removing the source filter would mix unrelated sessions and broaden rename and delete operations beyond VS Code ownership.
|
||||
Users should be able to open terminal-created conversations directly in VS Code. The source switch introduced in #11584 did not match that requirement: creation source is metadata, not a separate history or editing boundary.
|
||||
|
||||
## Decision
|
||||
|
||||
The history dialog keeps **VS Code** as its default source and adds an explicit **Terminal / Web** source. Each view uses the daemon's existing source-scoped query; there is no unfiltered catalog request and no change to the daemon or core pagination protocol.
|
||||
The history dialog shows ordinary conversations from the same workspace in one list, including VS Code, terminal, browser, and unattributed legacy conversations. There are no source tabs, source badges, or source-based restrictions on rename and delete. The existing protection against deleting the currently open conversation remains.
|
||||
|
||||
The VS Code view keeps the #11495 legacy allowlist scan. Allowlisted unattributed sessions are excluded from the Terminal / Web view so they appear once. Terminal / Web rows can be opened but are read-only in this surface: rename and delete remain limited to the VS Code view. For the sidebar host, the companion persists a session-id-bound ownership record beside the existing string session-id state, so reloads and interrupted writes preserve the foreign-session boundary without breaking downgrade compatibility. When a Terminal / Web row is opened, the companion omits VS Code restore attribution so the session's persisted source is not rewritten.
|
||||
The companion uses the existing workspace-scoped organized catalog with active archive state, all groups, and no source filter. This provides opaque, tie-safe pagination without changing the daemon protocol. Child sessions and explicitly attributed background-task/channel sessions are excluded from the conversation list. A visible Load more control permits advancing even when a page contains only filtered records. Failed requests retain the visible rows and cursor for retry; a truncated catalog produces an incomplete-history notice.
|
||||
|
||||
Each source change replaces the visible page, cursor, search state, and error. A request generation prevents a slower response from the previous source from overwriting the selected view.
|
||||
Only creation of a new conversation supplies VS Code source attribution. Restoring any existing conversation omits it, including after a reload. The host persists the existing string session id without requiring source sidecar state. Previously stored sidecars are ignored, and the old conversation store is left untouched. Legacy daemon conversations need no allowlist because the unified query already includes them. A bootstrap invalidates in-flight history requests and clears the previous runtime's rows and cursor.
|
||||
|
||||
## Scope
|
||||
|
||||
This is a VS Code companion UI change. It does not change daemon session ownership, core catalog pagination, Live session attribution, or the default Web Shell and terminal experiences.
|
||||
This is a VS Code companion change. Shared Web Shell, daemon ownership and pagination, Live attribution, and terminal behavior are unchanged. Missing source attribution on machine-generated sessions is a separate backend issue; the UI cannot reliably infer it.
|
||||
|
||||
## Verification
|
||||
|
||||
- The dialog opens on VS Code sessions and still includes allowlisted pre-cutover sessions.
|
||||
- Switching to Terminal / Web issues a `sourceType: default` query and excludes the legacy allowlist.
|
||||
- A late VS Code response cannot replace an already selected Terminal / Web page.
|
||||
- Terminal / Web rows have no rename or delete actions and opening one does not stamp it as VS Code.
|
||||
- One list includes ordinary VS Code, terminal, browser, and legacy conversations without a source selection.
|
||||
- Opening an existing conversation, including after a host reload, does not supply replacement source attribution; new conversations still carry VS Code attribution.
|
||||
- Ordinary conversations share the same row actions, with the current-conversation delete protection preserved.
|
||||
- Sparse pages can advance, failures can retry the same cursor, and incomplete history is visible.
|
||||
@@ -1,26 +1,26 @@
|
||||
# VS Code 会话来源切换
|
||||
# VS Code 统一会话历史
|
||||
|
||||
[English](./vscode-session-source-switch.md)
|
||||
|
||||
## 背景
|
||||
|
||||
同一工作区的 VS Code、终端和浏览器会话都保存在 daemon 中。VS Code 默认只展示自身会话;#11495 增加的升级恢复路径也只恢复扩展曾记录的旧会话 ID。直接移除来源过滤会混入无关会话,并让重命名和删除超出 VS Code 的归属范围。
|
||||
用户应能在 VS Code 中直接打开终端创建的会话。#11584 引入的来源切换不符合这一要求:创建来源只是元数据,不是历史列表或编辑权限的分界。
|
||||
|
||||
## 决策
|
||||
|
||||
历史会话弹窗继续默认展示 **VS Code** 来源,并新增显式的 **终端 / Web** 来源。两个视图都复用 daemon 现有的按来源查询;不发起无过滤目录请求,也不修改 daemon 或 core 的分页协议。
|
||||
历史弹窗在同一列表中展示当前工作区的普通会话,包括 VS Code、终端、浏览器和未标记来源的旧会话。不提供来源标签页或来源标识,也不按来源限制重命名、删除。保留禁止删除当前打开会话的既有保护。
|
||||
|
||||
VS Code 视图保留 #11495 的旧会话白名单扫描。白名单内的未标记会话会从终端 / Web 视图排除,避免重复展示。终端 / Web 会话可以打开,但在当前界面只读:重命名和删除仍仅限 VS Code 视图。对于侧栏宿主,companion 会在现有字符串会话 ID 状态旁保存一份与该 ID 绑定的历史归属记录,因此重载或写入中断都不会破坏外部会话边界,同时保留降级兼容。打开终端 / Web 会话时,companion 不携带 VS Code 恢复归属,因而不会改写已持久化的来源。
|
||||
Companion 复用现有的工作区 organized 目录,查询未归档会话和所有分组,不携带来源过滤。它使用不透明、同时间戳安全的分页游标,无须修改 daemon 协议。列表排除子会话和已有明确标记的后台任务、频道会话。“加载更多”按钮确保整页均被过滤时仍能翻页。请求失败保留已显示的行和游标以供重试;目录被截断时显示历史不完整提示。
|
||||
|
||||
切换来源时会替换当前列表、游标、搜索条件和错误状态。请求代次用于阻止上一来源的慢响应覆盖当前视图。
|
||||
仅创建新会话时传入 VS Code 来源;恢复任何已有会话时均不传入,包括扩展重载后的恢复。宿主继续保存既有的字符串会话 ID,不再依赖来源旁路状态。已保存的来源状态不再读取,旧会话存储保持不变。统一查询已包含旧 daemon 会话,不再需要白名单。Bootstrap 会作废未完成的历史请求,并清空上一 runtime 的列表和游标。
|
||||
|
||||
## 范围
|
||||
|
||||
本次是 VS Code companion 的界面改动,不改变 daemon 的会话归属、core 的目录分页、Live 会话标记,也不改变默认 Web Shell 和终端体验。
|
||||
本次仅修改 VS Code companion,不修改共享 Web Shell、daemon 的归属和分页、Live 来源标记或终端行为。机器生成会话缺少来源标记属于独立的后端问题,界面无法可靠推断。
|
||||
|
||||
## 验证
|
||||
|
||||
- 弹窗默认展示 VS Code 会话,并继续包含白名单内的升级前会话。
|
||||
- 切换到终端 / Web 后发送 `sourceType: default` 查询,并排除旧会话白名单。
|
||||
- 较晚返回的 VS Code 响应不能覆盖已经选中的终端 / Web 页面。
|
||||
- 终端 / Web 行不显示重命名或删除操作,打开后也不会被标记为 VS Code 来源。
|
||||
- 无须选择来源,同一列表显示普通 VS Code、终端、浏览器和旧会话。
|
||||
- 打开已有会话及宿主重载后的恢复均不传入新的来源归属;新建会话仍标记为 VS Code。
|
||||
- 普通会话共享相同的行操作,保留当前会话的删除保护。
|
||||
- 稀疏页面可以继续加载,失败后可以用原游标重试,历史不完整时有明确提示。
|
||||
@@ -192,14 +192,9 @@ describe('EmbeddedApp host wiring', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('does not claim a persisted foreign session after re-bootstrap', async () => {
|
||||
sdkMocks.listWorkspaceSessionsPage.mockResolvedValue({
|
||||
sessions: [],
|
||||
nextCursor: undefined,
|
||||
});
|
||||
await renderApp();
|
||||
const { container } = mounted[mounted.length - 1];
|
||||
|
||||
it('restores an existing session without setting its source', async () => {
|
||||
const props = await renderApp();
|
||||
expect(props.sessionSourceType).toBeUndefined();
|
||||
await act(async () => {
|
||||
window.dispatchEvent(
|
||||
new MessageEvent('message', {
|
||||
@@ -209,69 +204,14 @@ describe('EmbeddedApp host wiring', () => {
|
||||
baseUrl: 'http://localhost:4141',
|
||||
workspaceCwd: '/workspace',
|
||||
sessionId: 'cli-1',
|
||||
sessionHistorySource: 'default',
|
||||
hostKind: 'view',
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(
|
||||
(mocks.embeddedProps.current as CapturedProps).sessionSourceType,
|
||||
).toBeUndefined();
|
||||
|
||||
await act(async () => {
|
||||
(
|
||||
container.querySelector(
|
||||
'button[aria-haspopup="dialog"]',
|
||||
) as HTMLButtonElement
|
||||
).click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(sdkMocks.listWorkspaceSessionsPage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sourceType: 'vscode' }),
|
||||
);
|
||||
});
|
||||
expect(document.querySelector('[data-session-id="cli-1"]')).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
(
|
||||
document.querySelector(
|
||||
'[data-session-source="default"]',
|
||||
) as HTMLButtonElement
|
||||
).click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(
|
||||
document.querySelector('[data-session-id="cli-1"]'),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
window.dispatchEvent(
|
||||
new MessageEvent('message', {
|
||||
data: {
|
||||
type: 'webShellBootstrap',
|
||||
data: {
|
||||
baseUrl: 'http://localhost:4141',
|
||||
workspaceCwd: '/workspace',
|
||||
sessionId: 'legacy-1',
|
||||
hostKind: 'view',
|
||||
legacyConversationIds: ['legacy-1'],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(
|
||||
(mocks.embeddedProps.current as CapturedProps).sessionSourceType,
|
||||
).toBe('vscode');
|
||||
expect(mocks.embeddedProps.current?.sessionId).toBe('cli-1');
|
||||
expect(mocks.embeddedProps.current?.sessionSourceType).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps a persisted VS Code current session visible outside the first page', async () => {
|
||||
@@ -291,7 +231,6 @@ describe('EmbeddedApp host wiring', () => {
|
||||
baseUrl: 'http://localhost:4141',
|
||||
workspaceCwd: '/workspace',
|
||||
sessionId: 'vscode-current',
|
||||
sessionHistorySource: 'vscode',
|
||||
hostKind: 'view',
|
||||
},
|
||||
},
|
||||
@@ -302,7 +241,7 @@ describe('EmbeddedApp host wiring', () => {
|
||||
|
||||
expect(
|
||||
(mocks.embeddedProps.current as CapturedProps).sessionSourceType,
|
||||
).toBe('vscode');
|
||||
).toBeUndefined();
|
||||
await act(async () => {
|
||||
(
|
||||
container.querySelector(
|
||||
@@ -319,6 +258,34 @@ describe('EmbeddedApp host wiring', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('closes stale history when the host bootstraps again', async () => {
|
||||
await renderApp();
|
||||
const { container } = mounted[mounted.length - 1];
|
||||
await act(async () => {
|
||||
(
|
||||
container.querySelector(
|
||||
'button[aria-haspopup="dialog"]',
|
||||
) as HTMLButtonElement
|
||||
).click();
|
||||
});
|
||||
expect(container.querySelector('#qwen-session-history')).not.toBeNull();
|
||||
await act(async () => {
|
||||
window.dispatchEvent(
|
||||
new MessageEvent('message', {
|
||||
data: {
|
||||
type: 'webShellBootstrap',
|
||||
data: {
|
||||
baseUrl: 'http://localhost:4141',
|
||||
workspaceCwd: '/workspace',
|
||||
hostKind: 'view',
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(container.querySelector('#qwen-session-history')).toBeNull();
|
||||
});
|
||||
|
||||
it('attributes an internal new session to VS Code after a foreign clear', async () => {
|
||||
await renderApp();
|
||||
await act(async () => {
|
||||
@@ -330,7 +297,6 @@ describe('EmbeddedApp host wiring', () => {
|
||||
baseUrl: 'http://localhost:4141',
|
||||
workspaceCwd: '/workspace',
|
||||
sessionId: 'cli-1',
|
||||
sessionHistorySource: 'default',
|
||||
hostKind: 'view',
|
||||
},
|
||||
},
|
||||
@@ -888,7 +854,6 @@ describe('EmbeddedApp host wiring', () => {
|
||||
data: {
|
||||
sessionId: 'session-2',
|
||||
workspaceCwd: '/workspace',
|
||||
historySource: 'vscode',
|
||||
},
|
||||
});
|
||||
expect(postMessagesOfType('updatePanelTitle').at(-1)).toEqual({
|
||||
@@ -942,22 +907,15 @@ describe('EmbeddedApp host wiring', () => {
|
||||
});
|
||||
|
||||
it('releases the panel when a session switch times out', async () => {
|
||||
sdkMocks.listWorkspaceSessionsPage.mockImplementation(
|
||||
(options?: { sourceType?: string }) =>
|
||||
Promise.resolve({
|
||||
sessions:
|
||||
options?.sourceType === 'default'
|
||||
? [
|
||||
{
|
||||
sessionId: 'session-2',
|
||||
workspaceCwd: '/workspace',
|
||||
displayName: 'Other session',
|
||||
},
|
||||
]
|
||||
: [],
|
||||
nextCursor: undefined,
|
||||
}),
|
||||
);
|
||||
sdkMocks.listWorkspaceSessionsPage.mockResolvedValue({
|
||||
sessions: [
|
||||
{
|
||||
sessionId: 'session-2',
|
||||
workspaceCwd: '/workspace',
|
||||
displayName: 'Other session',
|
||||
},
|
||||
],
|
||||
});
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
await renderApp();
|
||||
@@ -971,14 +929,6 @@ describe('EmbeddedApp host wiring', () => {
|
||||
historyButton.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
(
|
||||
document.querySelector(
|
||||
'[data-session-source="default"]',
|
||||
) as HTMLButtonElement
|
||||
).click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const row = await vi.waitFor(() => {
|
||||
const session = document.querySelector(
|
||||
@@ -1034,7 +984,6 @@ describe('EmbeddedApp host wiring', () => {
|
||||
data: {
|
||||
sessionId: 'session-2',
|
||||
workspaceCwd: '/workspace',
|
||||
historySource: 'default',
|
||||
},
|
||||
});
|
||||
expect((mocks.embeddedProps.current as CapturedProps).sessionId).toBe(
|
||||
@@ -1045,265 +994,30 @@ describe('EmbeddedApp host wiring', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('merges allowlisted pre-cutover sessions into the history list', async () => {
|
||||
// v0.21-era conversations were recorded without source attribution, so
|
||||
// the vscode-scoped catalog query cannot return them. The host ships the
|
||||
// legacy ids it still has in globalState; the panel then claims exactly
|
||||
// those sessions back from the daemon's default catalog — without
|
||||
// surfacing unattributed CLI sessions or browser-stamped ones.
|
||||
sdkMocks.listWorkspaceSessionsPage.mockImplementation(
|
||||
(options?: { sourceType?: string }) => {
|
||||
if (options?.sourceType === 'vscode') {
|
||||
return Promise.resolve({
|
||||
sessions: [
|
||||
{
|
||||
sessionId: 'vscode-1',
|
||||
workspaceCwd: '/workspace',
|
||||
displayName: 'Current chat',
|
||||
sourceType: 'vscode',
|
||||
updatedAt: '2026-09-09T12:00:00.000Z',
|
||||
},
|
||||
],
|
||||
nextCursor: undefined,
|
||||
});
|
||||
}
|
||||
if (options?.sourceType === 'default') {
|
||||
return Promise.resolve({
|
||||
sessions: [
|
||||
{
|
||||
sessionId: 'legacy-1',
|
||||
workspaceCwd: '/workspace',
|
||||
displayName: 'Pre-upgrade chat',
|
||||
updatedAt: '2026-08-01T12:00:00.000Z',
|
||||
},
|
||||
{
|
||||
sessionId: 'cli-1',
|
||||
workspaceCwd: '/workspace',
|
||||
displayName: 'Terminal chat',
|
||||
updatedAt: '2026-08-02T12:00:00.000Z',
|
||||
},
|
||||
{
|
||||
sessionId: 'web-1',
|
||||
workspaceCwd: '/workspace',
|
||||
displayName: 'Browser chat',
|
||||
sourceType: 'default',
|
||||
updatedAt: '2026-08-03T12:00:00.000Z',
|
||||
},
|
||||
],
|
||||
nextCursor: undefined,
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ sessions: [], nextCursor: undefined });
|
||||
},
|
||||
);
|
||||
|
||||
await renderApp();
|
||||
const { container } = mounted[mounted.length - 1];
|
||||
|
||||
await act(async () => {
|
||||
window.dispatchEvent(
|
||||
new MessageEvent('message', {
|
||||
data: {
|
||||
type: 'webShellBootstrap',
|
||||
data: {
|
||||
baseUrl: 'http://localhost:4141',
|
||||
clientId: 'client-1',
|
||||
workspaceCwd: '/workspace',
|
||||
sessionId: 'session-1',
|
||||
hostKind: 'panel',
|
||||
legacyConversationIds: ['legacy-1', 'never-recorded'],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
// The remount effect refetches the vscode page, then pages the default
|
||||
// catalog for allowlisted ids — both are sequential awaits.
|
||||
for (let i = 0; i < 6; i++) await Promise.resolve();
|
||||
it('lists and opens workspace conversations together without a source switch', async () => {
|
||||
sdkMocks.listWorkspaceSessionsPage.mockResolvedValue({
|
||||
sessions: [
|
||||
{
|
||||
sessionId: 'vscode-1',
|
||||
sourceType: 'vscode',
|
||||
displayName: 'VS Code chat',
|
||||
},
|
||||
{ sessionId: 'cli-1', displayName: 'Terminal chat' },
|
||||
{
|
||||
sessionId: 'web-1',
|
||||
sourceType: 'default',
|
||||
displayName: 'Browser chat',
|
||||
},
|
||||
{ sessionId: 'legacy-1', displayName: 'Pre-upgrade chat' },
|
||||
{ sessionId: 'child-1', parentSessionId: 'cli-1' },
|
||||
{ sessionId: 'scheduled-1', sourceType: 'scheduled_task' },
|
||||
{
|
||||
sessionId: 'live-1',
|
||||
sourceType: 'default',
|
||||
sourceId: 'realtime_voice:call-1',
|
||||
},
|
||||
].map((session) => ({ ...session, workspaceCwd: '/workspace' })),
|
||||
});
|
||||
|
||||
const historyButton = container.querySelector(
|
||||
'button[aria-haspopup="dialog"]',
|
||||
) as HTMLButtonElement;
|
||||
expect(historyButton).not.toBeNull();
|
||||
await act(async () => {
|
||||
historyButton.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
// Opening the dropdown triggers the first-page load, which now includes
|
||||
// the legacy-catalog scan; wait for its rows instead of fixed flushes.
|
||||
await vi.waitFor(() => {
|
||||
expect(
|
||||
document.querySelector('[data-session-id="legacy-1"]'),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
expect(
|
||||
document.querySelector('[data-session-id="vscode-1"]'),
|
||||
).not.toBeNull();
|
||||
// Neither the unattributed CLI session nor the browser-stamped one is
|
||||
// allowlisted, so the panel must not claim them.
|
||||
expect(document.querySelector('[data-session-id="cli-1"]')).toBeNull();
|
||||
expect(document.querySelector('[data-session-id="web-1"]')).toBeNull();
|
||||
|
||||
const defaultCatalogCalls = sdkMocks.listWorkspaceSessionsPage.mock.calls
|
||||
.map(([options]) => options)
|
||||
.filter(
|
||||
(options) =>
|
||||
(options as { sourceType?: string })?.sourceType === 'default',
|
||||
);
|
||||
expect(defaultCatalogCalls.length).toBeGreaterThan(0);
|
||||
expect(defaultCatalogCalls[0]).toMatchObject({
|
||||
archiveState: 'active',
|
||||
});
|
||||
|
||||
// The scan converges after its first successful run: ids that never
|
||||
// match (other workspaces, deleted transcripts, since-stamped sessions)
|
||||
// must not re-page the default catalog on every dropdown open.
|
||||
await act(async () => {
|
||||
historyButton.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
historyButton.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
const vscodeCalls = sdkMocks.listWorkspaceSessionsPage.mock.calls.filter(
|
||||
([options]) =>
|
||||
(options as { sourceType?: string })?.sourceType === 'vscode',
|
||||
);
|
||||
expect(vscodeCalls.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
expect(
|
||||
sdkMocks.listWorkspaceSessionsPage.mock.calls.filter(
|
||||
([options]) =>
|
||||
(options as { sourceType?: string })?.sourceType === 'default',
|
||||
),
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
document.querySelector('[data-session-id="legacy-1"]'),
|
||||
).not.toBeNull();
|
||||
|
||||
const terminalSource = document.querySelector(
|
||||
'[data-session-source="default"]',
|
||||
) as HTMLButtonElement;
|
||||
await act(async () => {
|
||||
terminalSource.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(
|
||||
document.querySelector('[data-session-id="cli-1"]'),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
document.querySelector('[data-session-id="web-1"]'),
|
||||
).not.toBeNull();
|
||||
});
|
||||
expect(document.querySelector('[data-session-id="legacy-1"]')).toBeNull();
|
||||
expect(sdkMocks.listWorkspaceSessionsPage).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ pageSize: 20, sourceType: 'default' }),
|
||||
);
|
||||
|
||||
const defaultCallsAfterSwitch =
|
||||
sdkMocks.listWorkspaceSessionsPage.mock.calls.filter(
|
||||
([options]) =>
|
||||
(options as { sourceType?: string })?.sourceType === 'default',
|
||||
).length;
|
||||
await act(async () => {
|
||||
(
|
||||
document.querySelector(
|
||||
'[data-session-source="vscode"]',
|
||||
) as HTMLButtonElement
|
||||
).click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(
|
||||
document.querySelector('[data-session-id="legacy-1"]'),
|
||||
).not.toBeNull();
|
||||
});
|
||||
expect(
|
||||
sdkMocks.listWorkspaceSessionsPage.mock.calls.filter(
|
||||
([options]) =>
|
||||
(options as { sourceType?: string })?.sourceType === 'default',
|
||||
),
|
||||
).toHaveLength(defaultCallsAfterSwitch);
|
||||
|
||||
await act(async () => {
|
||||
(
|
||||
document.querySelector(
|
||||
'[data-session-source="default"]',
|
||||
) as HTMLButtonElement
|
||||
).click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(
|
||||
document.querySelector('[data-session-id="cli-1"]'),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
(
|
||||
document.querySelector('[data-session-id="cli-1"]') as HTMLDivElement
|
||||
).click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(
|
||||
(mocks.embeddedProps.current as CapturedProps).sessionSourceType,
|
||||
).toBeUndefined();
|
||||
callback<(sessionId: string | undefined) => void>(
|
||||
mocks.embeddedProps.current as CapturedProps,
|
||||
'onSessionIdChange',
|
||||
)('cli-1');
|
||||
expect(postMessagesOfType('webShellSessionChanged').at(-1)).toEqual({
|
||||
type: 'webShellSessionChanged',
|
||||
data: {
|
||||
sessionId: 'cli-1',
|
||||
workspaceCwd: '/workspace',
|
||||
historySource: 'default',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores a stale page after switching session sources', async () => {
|
||||
let resolveVsCodePage!: (value: {
|
||||
sessions: Array<{
|
||||
sessionId: string;
|
||||
workspaceCwd: string;
|
||||
displayName: string;
|
||||
sourceType: string;
|
||||
}>;
|
||||
nextCursor: undefined;
|
||||
}) => void;
|
||||
const vsCodePage = new Promise<{
|
||||
sessions: Array<{
|
||||
sessionId: string;
|
||||
workspaceCwd: string;
|
||||
displayName: string;
|
||||
sourceType: string;
|
||||
}>;
|
||||
nextCursor: undefined;
|
||||
}>((resolve) => {
|
||||
resolveVsCodePage = resolve;
|
||||
});
|
||||
sdkMocks.listWorkspaceSessionsPage.mockImplementation(
|
||||
(options?: { sourceType?: string }) =>
|
||||
options?.sourceType === 'vscode'
|
||||
? vsCodePage
|
||||
: Promise.resolve({
|
||||
sessions: [
|
||||
{
|
||||
sessionId: 'cli-1',
|
||||
workspaceCwd: '/workspace',
|
||||
displayName: 'Terminal chat',
|
||||
sourceType: 'default',
|
||||
},
|
||||
],
|
||||
nextCursor: undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
await renderApp();
|
||||
const { container } = mounted[mounted.length - 1];
|
||||
await act(async () => {
|
||||
@@ -1312,39 +1026,106 @@ describe('EmbeddedApp host wiring', () => {
|
||||
'button[aria-haspopup="dialog"]',
|
||||
) as HTMLButtonElement
|
||||
).click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(sdkMocks.listWorkspaceSessionsPage).toHaveBeenCalledExactlyOnceWith({
|
||||
pageSize: 20,
|
||||
cursor: undefined,
|
||||
archiveState: 'active',
|
||||
view: 'organized',
|
||||
group: 'all',
|
||||
});
|
||||
expect(container.querySelector('[data-session-source]')).toBeNull();
|
||||
for (const id of ['vscode-1', 'cli-1', 'web-1', 'legacy-1']) {
|
||||
const row = container.querySelector('[data-session-id="' + id + '"]');
|
||||
expect(row).not.toBeNull();
|
||||
expect(
|
||||
row?.querySelectorAll('.qwen-session-row-actions button'),
|
||||
).toHaveLength(2);
|
||||
}
|
||||
expect(container.querySelector('[data-session-id="child-1"]')).toBeNull();
|
||||
expect(container.querySelector('[data-session-id="live-1"]')).toBeNull();
|
||||
expect(
|
||||
container.querySelector('[data-session-id="scheduled-1"]'),
|
||||
).toBeNull();
|
||||
await act(async () => {
|
||||
(
|
||||
document.querySelector(
|
||||
'[data-session-source="default"]',
|
||||
) as HTMLButtonElement
|
||||
container.querySelector('[data-session-id="cli-1"]') as HTMLElement
|
||||
).click();
|
||||
await Promise.resolve();
|
||||
await new Promise<void>((resolve) =>
|
||||
requestAnimationFrame(() => resolve()),
|
||||
);
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(
|
||||
document.querySelector('[data-session-id="cli-1"]'),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
expect(mocks.embeddedProps.current?.sessionId).toBe('cli-1');
|
||||
expect(mocks.embeddedProps.current?.sessionSourceType).toBeUndefined();
|
||||
await act(async () => {
|
||||
resolveVsCodePage({
|
||||
callback<(sessionId: string | undefined) => void>(
|
||||
mocks.embeddedProps.current as CapturedProps,
|
||||
'onSessionIdChange',
|
||||
)('cli-1');
|
||||
});
|
||||
expect(postMessagesOfType('webShellSessionChanged').at(-1)).toEqual({
|
||||
type: 'webShellSessionChanged',
|
||||
data: { sessionId: 'cli-1', workspaceCwd: '/workspace' },
|
||||
});
|
||||
});
|
||||
|
||||
it('loads past a filtered page and retains the cursor after a failed request', async () => {
|
||||
sdkMocks.listWorkspaceSessionsPage
|
||||
.mockResolvedValueOnce({
|
||||
sessions: [
|
||||
{
|
||||
sessionId: 'vscode-1',
|
||||
sessionId: 'child-1',
|
||||
parentSessionId: 'parent-1',
|
||||
workspaceCwd: '/workspace',
|
||||
displayName: 'VS Code chat',
|
||||
sourceType: 'vscode',
|
||||
},
|
||||
],
|
||||
nextCursor: undefined,
|
||||
nextCursor: 'opaque-page-2',
|
||||
})
|
||||
.mockRejectedValueOnce(new Error('Temporary catalog failure'))
|
||||
.mockResolvedValueOnce({
|
||||
sessions: [
|
||||
{
|
||||
sessionId: 'cli-2',
|
||||
workspaceCwd: '/workspace',
|
||||
displayName: 'Older terminal chat',
|
||||
},
|
||||
],
|
||||
truncated: true,
|
||||
});
|
||||
await vsCodePage;
|
||||
await renderApp();
|
||||
const { container } = mounted[mounted.length - 1];
|
||||
await act(async () => {
|
||||
(
|
||||
container.querySelector(
|
||||
'button[aria-haspopup="dialog"]',
|
||||
) as HTMLButtonElement
|
||||
).click();
|
||||
});
|
||||
|
||||
expect(document.querySelector('[data-session-id="cli-1"]')).not.toBeNull();
|
||||
expect(document.querySelector('[data-session-id="vscode-1"]')).toBeNull();
|
||||
const loadMore = () =>
|
||||
Array.from(container.querySelectorAll('button')).find(
|
||||
(button) => button.textContent === 'Load more',
|
||||
);
|
||||
expect(loadMore()).toBeDefined();
|
||||
expect(container.querySelector('[data-session-id="child-1"]')).toBeNull();
|
||||
await act(async () => loadMore()!.click());
|
||||
expect(container.textContent).toContain('Temporary catalog failure');
|
||||
expect(
|
||||
container.querySelector('[data-session-id="session-1"]'),
|
||||
).not.toBeNull();
|
||||
expect(loadMore()).toBeDefined();
|
||||
await act(async () => loadMore()!.click());
|
||||
expect(sdkMocks.listWorkspaceSessionsPage).toHaveBeenLastCalledWith({
|
||||
pageSize: 20,
|
||||
cursor: 'opaque-page-2',
|
||||
archiveState: 'active',
|
||||
view: 'organized',
|
||||
group: 'all',
|
||||
});
|
||||
expect(container.querySelector('[data-session-id="cli-2"]')).not.toBeNull();
|
||||
expect(container.textContent).toContain(
|
||||
'Some conversations could not be loaded.',
|
||||
);
|
||||
expect(loadMore()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -40,63 +40,6 @@ import { isDiscontinuedModel } from './utils/discontinuedModel.js';
|
||||
|
||||
const SESSION_SWITCH_TIMEOUT_MS = 15_000;
|
||||
const SESSION_SWITCH_MIN_VISIBLE_MS = 120;
|
||||
type SessionHistorySource = 'vscode' | 'default';
|
||||
|
||||
/**
|
||||
* Bounds for the legacy-conversation scan: the allowlisted sessions sit in
|
||||
* the daemon's default catalog (mixed with CLI/browser sessions), paged
|
||||
* newest-first. Ten pages of a hundred keeps the worst case at a thousand
|
||||
* catalog reads while comfortably covering realistic histories.
|
||||
*/
|
||||
const LEGACY_SESSION_SCAN_PAGE_SIZE = 100;
|
||||
const LEGACY_SESSION_SCAN_MAX_PAGES = 10;
|
||||
|
||||
/**
|
||||
* Finds the panel's pre-cutover conversations inside the daemon's default
|
||||
* catalog. Those sessions carry no `sourceType` (attribution did not exist
|
||||
* yet), so the vscode-scoped history query never returns them; matching
|
||||
* against the companion's own legacy id list claims back exactly the sessions
|
||||
* this surface recorded, without pulling in unattributed CLI sessions that
|
||||
* merely share the workspace. Throws on catalog errors — the caller decides
|
||||
* the failure policy.
|
||||
*/
|
||||
async function loadLegacyAllowlistedSessions(
|
||||
daemonClient: DaemonClient,
|
||||
workspaceCwd: string,
|
||||
legacyConversationIds: readonly string[],
|
||||
): Promise<DaemonSessionSummary[]> {
|
||||
const remaining = new Set(legacyConversationIds);
|
||||
const found: DaemonSessionSummary[] = [];
|
||||
let cursor: string | undefined;
|
||||
for (
|
||||
let page = 0;
|
||||
page < LEGACY_SESSION_SCAN_MAX_PAGES && remaining.size > 0;
|
||||
page++
|
||||
) {
|
||||
const result = await daemonClient
|
||||
.workspaceByCwd(workspaceCwd)
|
||||
.listWorkspaceSessionsPage({
|
||||
pageSize: LEGACY_SESSION_SCAN_PAGE_SIZE,
|
||||
cursor,
|
||||
archiveState: 'active',
|
||||
// Unattributed sessions file under the default catalog server-side.
|
||||
sourceType: 'default',
|
||||
});
|
||||
for (const session of result.sessions) {
|
||||
// Sessions stamped `default` belong to the CLI/Web Shell, not to the
|
||||
// pre-attribution companion history this scan exists to recover.
|
||||
if (
|
||||
session.sourceType === undefined &&
|
||||
remaining.delete(session.sessionId)
|
||||
) {
|
||||
found.push(session);
|
||||
}
|
||||
}
|
||||
if (!result.nextCursor) break;
|
||||
cursor = result.nextCursor;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
const COMPOSER_TOOLBAR_ACTIONS = [
|
||||
'approvalMode',
|
||||
@@ -271,16 +214,7 @@ interface RuntimeConfig {
|
||||
*/
|
||||
editorWorkspaceCwd?: string;
|
||||
sessionId?: string;
|
||||
sessionHistorySource?: SessionHistorySource;
|
||||
hostKind?: 'view' | 'panel';
|
||||
/**
|
||||
* Ids of conversations the pre-cutover companion recorded in VS Code
|
||||
* globalState. Their daemon transcripts carry no source attribution, so the
|
||||
* vscode-scoped history query never surfaces them on its own; the history
|
||||
* list uses these ids as an allowlist to claim matching unattributed daemon
|
||||
* sessions back.
|
||||
*/
|
||||
legacyConversationIds?: string[];
|
||||
}
|
||||
|
||||
function readRuntimeConfig(): RuntimeConfig | null {
|
||||
@@ -301,12 +235,6 @@ function readRuntimeConfig(): RuntimeConfig | null {
|
||||
};
|
||||
}
|
||||
|
||||
function historySourceForRuntime(
|
||||
runtime: RuntimeConfig | null,
|
||||
): SessionHistorySource {
|
||||
return runtime?.sessionHistorySource === 'default' ? 'default' : 'vscode';
|
||||
}
|
||||
|
||||
interface ActiveFileContext {
|
||||
fileName: string;
|
||||
filePath: string;
|
||||
@@ -394,12 +322,6 @@ export function EmbeddedApp() {
|
||||
const [includeActiveFile, setIncludeActiveFile] = useState(true);
|
||||
const [sessionTitle, setSessionTitle] = useState(() => t('session.new'));
|
||||
const [sessionHistoryOpen, setSessionHistoryOpen] = useState(false);
|
||||
const [sessionHistorySource, setSessionHistorySource] =
|
||||
useState<SessionHistorySource>(VSCODE_SESSION_SOURCE_TYPE);
|
||||
const [activeSessionHistorySource, setActiveSessionHistorySource] =
|
||||
useState<SessionHistorySource>(() =>
|
||||
historySourceForRuntime(initialRuntime),
|
||||
);
|
||||
const [sessionSearchQuery, setSessionSearchQuery] = useState('');
|
||||
const [sessions, setSessions] = useState<DaemonSessionSummary[]>([]);
|
||||
const [sessionCursor, setSessionCursor] = useState<string>();
|
||||
@@ -416,10 +338,6 @@ export function EmbeddedApp() {
|
||||
| undefined
|
||||
>(undefined);
|
||||
const sessionSwitchStartedAtRef = useRef(0);
|
||||
// The allowlist is fixed for one bootstrap, so one successful scan is
|
||||
// enough until the host supplies a fresh list.
|
||||
const legacyScanDoneRef = useRef(false);
|
||||
const legacySessionsRef = useRef<DaemonSessionSummary[]>([]);
|
||||
const sessionHistoryRequestRef = useRef(0);
|
||||
const sessionSwitchTimerRef = useRef<
|
||||
ReturnType<typeof setTimeout> | undefined
|
||||
@@ -459,12 +377,6 @@ export function EmbeddedApp() {
|
||||
composerRef.current?.focus?.();
|
||||
}, []);
|
||||
|
||||
// A re-bootstrap delivers a fresh allowlist — reopen the scan for it.
|
||||
useEffect(() => {
|
||||
legacyScanDoneRef.current = false;
|
||||
legacySessionsRef.current = [];
|
||||
}, [runtime?.legacyConversationIds]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (sessionSwitchTimerRef.current) {
|
||||
@@ -506,15 +418,8 @@ export function EmbeddedApp() {
|
||||
}, [switchingSessionId, creatingSession, t]);
|
||||
|
||||
const loadSessionHistory = useCallback(
|
||||
async (
|
||||
cursor?: string,
|
||||
source: SessionHistorySource = sessionHistorySource,
|
||||
) => {
|
||||
if (
|
||||
!daemonClient ||
|
||||
!runtime?.workspaceCwd ||
|
||||
(sessionListLoading && source === sessionHistorySource)
|
||||
) {
|
||||
async (cursor?: string) => {
|
||||
if (!daemonClient || !runtime?.workspaceCwd || sessionListLoading) {
|
||||
return;
|
||||
}
|
||||
const requestId = ++sessionHistoryRequestRef.current;
|
||||
@@ -527,60 +432,37 @@ export function EmbeddedApp() {
|
||||
pageSize: 20,
|
||||
cursor,
|
||||
archiveState: 'active',
|
||||
sourceType: source,
|
||||
view: 'organized',
|
||||
group: 'all',
|
||||
});
|
||||
const legacyIds = runtime.legacyConversationIds;
|
||||
const pageSessions = (
|
||||
Array.isArray(page.sessions) ? page.sessions : []
|
||||
).filter(
|
||||
(session) =>
|
||||
source !== 'default' || !legacyIds?.includes(session.sessionId),
|
||||
!session.parentSessionId &&
|
||||
// Live voice threads are ordinary 'default' sessions carrying a
|
||||
// sourceId built from LIVE_SESSION_SOURCE_PREFIX in packages/cli;
|
||||
// the literal is inlined since the CLI is not a bundle dependency.
|
||||
!(
|
||||
session.sourceType === 'default' &&
|
||||
session.sourceId?.startsWith('realtime_voice:')
|
||||
) &&
|
||||
!['scheduled_task', 'side_task', 'channel', 'qwen-live'].includes(
|
||||
session.sourceType ?? '',
|
||||
),
|
||||
);
|
||||
// First page only, once per bootstrap: recover the pre-cutover
|
||||
// conversations the daemon files as unattributed. Restoring one
|
||||
// stamps it `vscode` (the daemon fills missing attribution on
|
||||
// restore), so later loads surface it through the ordinary query
|
||||
// above. A scan failure must not take the ordinary history list down
|
||||
// with it — fail open and retry on a later open.
|
||||
let legacySessions =
|
||||
source === VSCODE_SESSION_SOURCE_TYPE
|
||||
? legacySessionsRef.current
|
||||
: [];
|
||||
if (
|
||||
cursor === undefined &&
|
||||
source === VSCODE_SESSION_SOURCE_TYPE &&
|
||||
!legacyScanDoneRef.current &&
|
||||
legacyIds !== undefined &&
|
||||
legacyIds.length > 0
|
||||
) {
|
||||
try {
|
||||
legacySessions = await loadLegacyAllowlistedSessions(
|
||||
daemonClient,
|
||||
runtime.workspaceCwd,
|
||||
legacyIds,
|
||||
);
|
||||
if (requestId !== sessionHistoryRequestRef.current) return;
|
||||
legacySessionsRef.current = legacySessions;
|
||||
legacyScanDoneRef.current = true;
|
||||
} catch {
|
||||
legacySessions = [];
|
||||
}
|
||||
}
|
||||
if (requestId !== sessionHistoryRequestRef.current) return;
|
||||
setSessions((current) => {
|
||||
const merged = new Map(
|
||||
current.map((session) => [session.sessionId, session]),
|
||||
(cursor ? current : []).map((session) => [
|
||||
session.sessionId,
|
||||
session,
|
||||
]),
|
||||
);
|
||||
for (const session of pageSessions) {
|
||||
merged.set(session.sessionId, session);
|
||||
}
|
||||
for (const session of legacySessions) {
|
||||
if (!merged.has(session.sessionId)) {
|
||||
merged.set(session.sessionId, session);
|
||||
}
|
||||
}
|
||||
if (
|
||||
source === activeSessionHistorySource &&
|
||||
runtime.sessionId &&
|
||||
!merged.has(runtime.sessionId) &&
|
||||
runtime.workspaceCwd
|
||||
@@ -594,6 +476,9 @@ export function EmbeddedApp() {
|
||||
return Array.from(merged.values());
|
||||
});
|
||||
setSessionCursor(page.nextCursor);
|
||||
setSessionListError(
|
||||
page.truncated ? t('session.historyIncomplete') : undefined,
|
||||
);
|
||||
} catch (error) {
|
||||
if (requestId !== sessionHistoryRequestRef.current) return;
|
||||
setSessionListError(
|
||||
@@ -607,11 +492,8 @@ export function EmbeddedApp() {
|
||||
},
|
||||
[
|
||||
daemonClient,
|
||||
activeSessionHistorySource,
|
||||
runtime?.sessionId,
|
||||
runtime?.workspaceCwd,
|
||||
runtime?.legacyConversationIds,
|
||||
sessionHistorySource,
|
||||
sessionListLoading,
|
||||
sessionTitle,
|
||||
t,
|
||||
@@ -837,7 +719,12 @@ export function EmbeddedApp() {
|
||||
const nextRuntime = message.data as NonNullable<
|
||||
ReturnType<typeof readRuntimeConfig>
|
||||
>;
|
||||
setActiveSessionHistorySource(historySourceForRuntime(nextRuntime));
|
||||
sessionHistoryRequestRef.current++;
|
||||
setSessionHistoryOpen(false);
|
||||
setSessions([]);
|
||||
setSessionCursor(undefined);
|
||||
setSessionListLoading(false);
|
||||
setSessionListError(undefined);
|
||||
setRuntime(nextRuntime);
|
||||
} else if (message.type === 'webShellBootstrapError') {
|
||||
const errorMessage = (message.data as { message?: unknown } | null)
|
||||
@@ -1123,31 +1010,18 @@ export function EmbeddedApp() {
|
||||
<style>{VSCODE_EMBEDDED_CSS}</style>
|
||||
{sessionHistoryOpen && (
|
||||
<SessionHistoryDropdown
|
||||
key={sessionHistorySource}
|
||||
t={t}
|
||||
sessions={sessions}
|
||||
currentSessionId={runtime.sessionId}
|
||||
searchQuery={sessionSearchQuery}
|
||||
source={sessionHistorySource}
|
||||
editable={sessionHistorySource === VSCODE_SESSION_SOURCE_TYPE}
|
||||
loading={sessionListLoading}
|
||||
hasMore={Boolean(sessionCursor)}
|
||||
error={sessionListError}
|
||||
onSearchChange={setSessionSearchQuery}
|
||||
onSourceChange={(source) => {
|
||||
if (source === sessionHistorySource) return;
|
||||
setSessionHistorySource(source);
|
||||
setSessionSearchQuery('');
|
||||
setSessions([]);
|
||||
setSessionCursor(undefined);
|
||||
setSessionListError(undefined);
|
||||
void loadSessionHistory(undefined, source);
|
||||
}}
|
||||
onClose={closeSessionHistory}
|
||||
onLoadMore={() => void loadSessionHistory(sessionCursor)}
|
||||
onSelect={(session) => {
|
||||
if (session.sessionId === runtime.sessionId) return;
|
||||
setActiveSessionHistorySource(sessionHistorySource);
|
||||
closeOpenPermissionDiffs();
|
||||
clearInsight();
|
||||
closeSessionHistory();
|
||||
@@ -1165,11 +1039,7 @@ export function EmbeddedApp() {
|
||||
});
|
||||
}}
|
||||
onRename={async (session, title) => {
|
||||
if (
|
||||
sessionHistorySource !== VSCODE_SESSION_SOURCE_TYPE ||
|
||||
!daemonClient ||
|
||||
!runtime.workspaceCwd
|
||||
) {
|
||||
if (!daemonClient || !runtime.workspaceCwd) {
|
||||
return;
|
||||
}
|
||||
setSessionListError(undefined);
|
||||
@@ -1180,12 +1050,6 @@ export function EmbeddedApp() {
|
||||
displayName: title,
|
||||
});
|
||||
const displayName = result.displayName || title;
|
||||
legacySessionsRef.current = legacySessionsRef.current.map(
|
||||
(entry) =>
|
||||
entry.sessionId === session.sessionId
|
||||
? { ...entry, displayName }
|
||||
: entry,
|
||||
);
|
||||
setSessions((current) =>
|
||||
current.map((entry) =>
|
||||
entry.sessionId === session.sessionId
|
||||
@@ -1212,7 +1076,6 @@ export function EmbeddedApp() {
|
||||
}}
|
||||
onDelete={async (session) => {
|
||||
if (
|
||||
sessionHistorySource !== VSCODE_SESSION_SOURCE_TYPE ||
|
||||
!daemonClient ||
|
||||
!runtime.workspaceCwd ||
|
||||
!session.sessionId ||
|
||||
@@ -1225,9 +1088,6 @@ export function EmbeddedApp() {
|
||||
await daemonClient
|
||||
.workspaceByCwd(runtime.workspaceCwd)
|
||||
.deleteSessionsData([session.sessionId]);
|
||||
legacySessionsRef.current = legacySessionsRef.current.filter(
|
||||
(entry) => entry.sessionId !== session.sessionId,
|
||||
);
|
||||
setSessions((current) =>
|
||||
current.filter(
|
||||
(entry) => entry.sessionId !== session.sessionId,
|
||||
@@ -1579,17 +1439,12 @@ export function EmbeddedApp() {
|
||||
theme={theme}
|
||||
language={language}
|
||||
sessionSourceType={
|
||||
activeSessionHistorySource === VSCODE_SESSION_SOURCE_TYPE
|
||||
? VSCODE_SESSION_SOURCE_TYPE
|
||||
: undefined
|
||||
runtime.sessionId ? undefined : VSCODE_SESSION_SOURCE_TYPE
|
||||
}
|
||||
shellRef={shellRef}
|
||||
header={{ items: [] }}
|
||||
onSessionIdChange={(sessionId) => {
|
||||
if (switchingSessionId && sessionId !== switchingSessionId) return;
|
||||
if (sessionId === undefined) {
|
||||
setActiveSessionHistorySource(VSCODE_SESSION_SOURCE_TYPE);
|
||||
}
|
||||
webShellPermissionRequestIdRef.current = undefined;
|
||||
clearInsight();
|
||||
setEditingMessage(undefined);
|
||||
@@ -1598,7 +1453,6 @@ export function EmbeddedApp() {
|
||||
data: {
|
||||
sessionId,
|
||||
workspaceCwd: runtime.workspaceCwd,
|
||||
historySource: activeSessionHistorySource,
|
||||
},
|
||||
});
|
||||
setRuntime((current) =>
|
||||
|
||||
+17
-32
@@ -33,9 +33,9 @@ function makeSession(
|
||||
|
||||
const mounted: Array<{ container: HTMLElement; root: Root }> = [];
|
||||
|
||||
async function renderDropdown(editable = true) {
|
||||
async function renderDropdown(hasMore = false) {
|
||||
const onClose = vi.fn();
|
||||
const onSourceChange = vi.fn();
|
||||
const onLoadMore = vi.fn();
|
||||
const onSelect = vi.fn();
|
||||
const onRename = vi.fn(async () => {});
|
||||
const container = document.createElement('div');
|
||||
@@ -48,23 +48,20 @@ async function renderDropdown(editable = true) {
|
||||
sessions={[makeSession('s1', 'First'), makeSession('s2', 'Second')]}
|
||||
currentSessionId="s1"
|
||||
searchQuery=""
|
||||
source={editable ? 'vscode' : 'default'}
|
||||
editable={editable}
|
||||
loading={false}
|
||||
hasMore={false}
|
||||
hasMore={hasMore}
|
||||
onSearchChange={() => {}}
|
||||
onSourceChange={onSourceChange}
|
||||
onSelect={onSelect}
|
||||
onRename={onRename}
|
||||
onDelete={async () => {}}
|
||||
onLoadMore={() => {}}
|
||||
onLoadMore={onLoadMore}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
mounted.push({ container, root });
|
||||
return { container, onClose, onSourceChange };
|
||||
return { container, onClose, onLoadMore };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
@@ -165,29 +162,17 @@ describe('SessionHistoryDropdown focus management', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('SessionHistoryDropdown sources', () => {
|
||||
it('switches sources from the source buttons', async () => {
|
||||
const { container, onSourceChange } = await renderDropdown();
|
||||
const terminalSource = container.querySelector(
|
||||
'[data-session-source="default"]',
|
||||
) as HTMLButtonElement;
|
||||
|
||||
await act(async () => {
|
||||
terminalSource.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(onSourceChange).toHaveBeenCalledWith('default');
|
||||
});
|
||||
|
||||
it('does not expose rename or delete for non-VS Code sessions', async () => {
|
||||
const { container } = await renderDropdown(false);
|
||||
|
||||
expect(
|
||||
container.querySelector(`[aria-label="${t('session.renameLabel')}"]`),
|
||||
).toBeNull();
|
||||
expect(
|
||||
container.querySelector(`[aria-label="${t('session.deleteLabel')}"]`),
|
||||
).toBeNull();
|
||||
describe('SessionHistoryDropdown pagination', () => {
|
||||
it('offers a focusable load-more control without requiring scroll overflow', async () => {
|
||||
const { container, onLoadMore } = await renderDropdown(true);
|
||||
expect(container.querySelector('[data-session-source]')).toBeNull();
|
||||
const loadMore = Array.from(container.querySelectorAll('button')).find(
|
||||
(button) => button.textContent === t('session.loadMore'),
|
||||
);
|
||||
expect(loadMore).toBeDefined();
|
||||
loadMore!.focus();
|
||||
expect(document.activeElement).toBe(loadMore);
|
||||
await act(async () => loadMore!.click());
|
||||
expect(onLoadMore).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -14,13 +14,10 @@ interface SessionHistoryDropdownProps {
|
||||
sessions: readonly DaemonSessionSummary[];
|
||||
currentSessionId?: string;
|
||||
searchQuery: string;
|
||||
source: 'vscode' | 'default';
|
||||
editable: boolean;
|
||||
loading: boolean;
|
||||
hasMore: boolean;
|
||||
error?: string;
|
||||
onSearchChange: (query: string) => void;
|
||||
onSourceChange: (source: 'vscode' | 'default') => void;
|
||||
onSelect: (session: DaemonSessionSummary) => void;
|
||||
onRename: (session: DaemonSessionSummary, title: string) => Promise<void>;
|
||||
onDelete: (session: DaemonSessionSummary) => Promise<void>;
|
||||
@@ -94,7 +91,6 @@ const DROPDOWN_CSS = `
|
||||
.qwen-session-row-actions[data-confirming] { visibility: visible; }
|
||||
.qwen-session-row:focus-visible,
|
||||
.qwen-session-search:focus-visible,
|
||||
.qwen-session-source-button:focus-visible,
|
||||
.qwen-session-icon-button:focus-visible {
|
||||
outline: 1px solid var(--vscode-focusBorder);
|
||||
outline-offset: -1px;
|
||||
@@ -109,13 +105,10 @@ export function SessionHistoryDropdown({
|
||||
sessions,
|
||||
currentSessionId,
|
||||
searchQuery,
|
||||
source,
|
||||
editable,
|
||||
loading,
|
||||
hasMore,
|
||||
error,
|
||||
onSearchChange,
|
||||
onSourceChange,
|
||||
onSelect,
|
||||
onRename,
|
||||
onDelete,
|
||||
@@ -270,41 +263,6 @@ export function SessionHistoryDropdown({
|
||||
}}
|
||||
>
|
||||
<style>{DROPDOWN_CSS}</style>
|
||||
<div
|
||||
role="group"
|
||||
aria-label={t('session.sourceLabel')}
|
||||
style={{ display: 'flex', gap: 4, padding: '8px 10px 0' }}
|
||||
>
|
||||
{(['vscode', 'default'] as const).map((value) => {
|
||||
const active = source === value;
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
className="qwen-session-source-button"
|
||||
aria-pressed={active}
|
||||
data-session-source={value}
|
||||
onClick={() => onSourceChange(value)}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '4px 8px',
|
||||
border: 0,
|
||||
borderRadius: 4,
|
||||
background: active
|
||||
? 'var(--vscode-list-activeSelectionBackground)'
|
||||
: 'transparent',
|
||||
color: active
|
||||
? 'var(--vscode-list-activeSelectionForeground)'
|
||||
: 'inherit',
|
||||
font: 'inherit',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{t(`session.source.${value}`)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
@@ -539,7 +497,7 @@ export function SessionHistoryDropdown({
|
||||
</span>
|
||||
)}
|
||||
|
||||
{!renaming && editable && (
|
||||
{!renaming && (
|
||||
<span
|
||||
className="qwen-session-row-actions"
|
||||
{...(confirmDeleteId === session.sessionId
|
||||
@@ -620,7 +578,7 @@ export function SessionHistoryDropdown({
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!loading && filtered.length === 0 && (
|
||||
{!loading && !hasMore && filtered.length === 0 && (
|
||||
<div
|
||||
style={{
|
||||
padding: 20,
|
||||
@@ -652,6 +610,24 @@ export function SessionHistoryDropdown({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{hasMore && !loading && (
|
||||
<button
|
||||
type="button"
|
||||
className="qwen-session-icon-button"
|
||||
onClick={onLoadMore}
|
||||
style={{
|
||||
padding: 8,
|
||||
border: 0,
|
||||
borderRadius: 3,
|
||||
background: 'transparent',
|
||||
color: 'inherit',
|
||||
font: 'inherit',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{t('session.loadMore')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -2398,160 +2398,38 @@ describe('WebViewProvider web-shell daemon bootstrap', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('persists history ownership beside the view session id', async () => {
|
||||
it('persists and restores a terminal session without source sidecar state', async () => {
|
||||
const context = createSessionStateContext({});
|
||||
const setup = await setupAttachedProvider({
|
||||
captureMessageHandler: true,
|
||||
context,
|
||||
});
|
||||
|
||||
await setup.messageHandler?.({
|
||||
type: 'webShellSessionChanged',
|
||||
data: {
|
||||
sessionId: 'terminal-session',
|
||||
workspaceCwd: '/workspace-a',
|
||||
historySource: 'default',
|
||||
},
|
||||
data: { sessionId: 'terminal-session', workspaceCwd: '/workspace-a' },
|
||||
});
|
||||
await setup.messageHandler?.({ type: 'webShellReady' });
|
||||
|
||||
expect(
|
||||
context.workspaceState.get(`${WEB_SHELL_SESSION_KEY_PREFIX}/workspace-a`),
|
||||
context.workspaceState.get(WEB_SHELL_SESSION_KEY_PREFIX + '/workspace-a'),
|
||||
).toBe('terminal-session');
|
||||
expect(
|
||||
context.workspaceState.get(
|
||||
'qwenCode.webShellSessionSource:/workspace-a:terminal-session',
|
||||
),
|
||||
).toBe('default');
|
||||
expect(setup.postMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'webShellBootstrap',
|
||||
data: expect.objectContaining({
|
||||
sessionId: 'terminal-session',
|
||||
sessionHistorySource: 'default',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('restores history ownership only when it matches the view session', async () => {
|
||||
const context = createSessionStateContext({
|
||||
[`${WEB_SHELL_SESSION_KEY_PREFIX}/workspace-a`]: 'terminal-session',
|
||||
'qwenCode.webShellSessionSource:/workspace-a:terminal-session': 'default',
|
||||
'qwenCode.webShellSessionSource:/workspace-a:new-vscode-session':
|
||||
'vscode',
|
||||
});
|
||||
const setup = await setupAttachedProvider({
|
||||
const reloaded = await setupAttachedProvider({
|
||||
captureMessageHandler: true,
|
||||
context,
|
||||
});
|
||||
|
||||
await setup.messageHandler?.({ type: 'webShellReady' });
|
||||
|
||||
expect(setup.postMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'webShellBootstrap',
|
||||
data: expect.objectContaining({
|
||||
sessionId: 'terminal-session',
|
||||
sessionHistorySource: 'default',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores stale history ownership for a different view session', async () => {
|
||||
const context = createSessionStateContext({
|
||||
[`${WEB_SHELL_SESSION_KEY_PREFIX}/workspace-a`]: 'vscode-session',
|
||||
'qwenCode.webShellSessionSource:/workspace-a:old-terminal-session':
|
||||
'default',
|
||||
});
|
||||
const setup = await setupAttachedProvider({
|
||||
captureMessageHandler: true,
|
||||
context,
|
||||
});
|
||||
|
||||
await setup.messageHandler?.({ type: 'webShellReady' });
|
||||
|
||||
expect(setup.postMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'webShellBootstrap',
|
||||
data: expect.objectContaining({
|
||||
sessionId: 'vscode-session',
|
||||
sessionHistorySource: 'vscode',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('ships restorable legacy conversation ids in the bootstrap payload', async () => {
|
||||
// Pre-cutover conversations whose prompts reached the daemon were renamed
|
||||
// to the ACP session id; entries that never left the panel keep their
|
||||
// conv_*/temp* id and have no daemon transcript to restore.
|
||||
conversationStoreMocks.getAllConversations.mockResolvedValue([
|
||||
{
|
||||
id: 'conv_1757000000000_abc123',
|
||||
title: 'Empty draft',
|
||||
messages: [],
|
||||
},
|
||||
{
|
||||
id: '550e8400-e29b-41d4-a716-446655440201',
|
||||
title: 'Pre-upgrade chat',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
},
|
||||
{
|
||||
id: 'temp-scratch',
|
||||
title: 'Scratch',
|
||||
messages: [],
|
||||
},
|
||||
]);
|
||||
const context = createSessionStateContext({});
|
||||
const setup = await setupAttachedProvider({
|
||||
captureMessageHandler: true,
|
||||
context,
|
||||
});
|
||||
|
||||
await setup.messageHandler?.({ type: 'webShellReady' });
|
||||
|
||||
expect(setup.postMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'webShellBootstrap',
|
||||
data: expect.objectContaining({
|
||||
legacyConversationIds: ['550e8400-e29b-41d4-a716-446655440201'],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
// The legacy store doubles as the downgrade/recovery path; the bootstrap
|
||||
// must stay read-only against it.
|
||||
expect(conversationStoreMocks.getAllConversations).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('omits the legacy allowlist when no restorable conversation exists', async () => {
|
||||
conversationStoreMocks.getAllConversations.mockResolvedValue([
|
||||
{
|
||||
id: 'conv_1757000000000_abc123',
|
||||
title: 'Empty draft',
|
||||
messages: [],
|
||||
},
|
||||
]);
|
||||
const context = createSessionStateContext({});
|
||||
const setup = await setupAttachedProvider({
|
||||
captureMessageHandler: true,
|
||||
context,
|
||||
});
|
||||
|
||||
await setup.messageHandler?.({ type: 'webShellReady' });
|
||||
|
||||
const bootstrap = setup.postMessage.mock.calls
|
||||
await reloaded.messageHandler?.({ type: 'webShellReady' });
|
||||
const bootstrap = reloaded.postMessage.mock.calls
|
||||
.map(
|
||||
([message]) =>
|
||||
message as {
|
||||
type?: string;
|
||||
data?: { legacyConversationIds?: string[] };
|
||||
},
|
||||
message as { type?: string; data?: Record<string, unknown> },
|
||||
)
|
||||
.find((message) => message.type === 'webShellBootstrap');
|
||||
expect(bootstrap).toBeDefined();
|
||||
expect(bootstrap?.data?.legacyConversationIds).toBeUndefined();
|
||||
expect(bootstrap?.data?.sessionId).toBe('terminal-session');
|
||||
expect(bootstrap?.data).not.toHaveProperty('sessionHistorySource');
|
||||
expect(
|
||||
context.workspaceState.update.mock.calls.map(([key]) => key),
|
||||
).not.toContain(
|
||||
'qwenCode.webShellSessionSource:/workspace-a:terminal-session',
|
||||
);
|
||||
});
|
||||
|
||||
it('restores a session id persisted under the pre-canonicalization key', async () => {
|
||||
|
||||
@@ -115,8 +115,6 @@ function isInsightCommand(command: string): boolean {
|
||||
}
|
||||
|
||||
const WEB_SHELL_SESSION_STATE_PREFIX = 'qwenCode.webShellSessionId:';
|
||||
const WEB_SHELL_SESSION_SOURCE_STATE_PREFIX = 'qwenCode.webShellSessionSource:';
|
||||
type SessionHistorySource = 'vscode' | 'default';
|
||||
|
||||
function getRestorableDaemonSessionId(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
@@ -135,13 +133,6 @@ function webShellSessionStateKey(workspaceCwd: string): string {
|
||||
return `${WEB_SHELL_SESSION_STATE_PREFIX}${workspaceCwd}`;
|
||||
}
|
||||
|
||||
function webShellSessionSourceStateKey(
|
||||
workspaceCwd: string,
|
||||
sessionId: string,
|
||||
): string {
|
||||
return `${WEB_SHELL_SESSION_SOURCE_STATE_PREFIX}${workspaceCwd}:${sessionId}`;
|
||||
}
|
||||
|
||||
export class WebViewProvider {
|
||||
private panelManager: PanelManager;
|
||||
private messageHandler: MessageHandler;
|
||||
@@ -156,7 +147,6 @@ export class WebViewProvider {
|
||||
* (a reload re-runs the bootstrap) while staying distinct per chat host.
|
||||
*/
|
||||
private readonly daemonClientId = `vscode-${randomUUID()}`;
|
||||
private webShellSessionHistorySource: SessionHistorySource = 'vscode';
|
||||
private disposables: vscode.Disposable[] = [];
|
||||
private agentInitialized = false; // Track if agent has been initialized
|
||||
private isSyncingToVSCode = false; // Guard to prevent config change loop
|
||||
@@ -1966,46 +1956,15 @@ export class WebViewProvider {
|
||||
| {
|
||||
sessionId?: unknown;
|
||||
workspaceCwd?: unknown;
|
||||
historySource?: unknown;
|
||||
}
|
||||
| undefined;
|
||||
const sessionId = getRestorableDaemonSessionId(data?.sessionId) ?? null;
|
||||
this.webShellSessionHistorySource =
|
||||
sessionId && data?.historySource === 'default' ? 'default' : 'vscode';
|
||||
this.messageHandler.setCurrentConversationId(sessionId);
|
||||
if (this.isViewHost && typeof data?.workspaceCwd === 'string') {
|
||||
const sessionKey = webShellSessionStateKey(data.workspaceCwd);
|
||||
const previousSessionId = getRestorableDaemonSessionId(
|
||||
this.context.workspaceState.get<string>(sessionKey),
|
||||
await this.context.workspaceState.update(
|
||||
webShellSessionStateKey(data.workspaceCwd),
|
||||
sessionId ?? undefined,
|
||||
);
|
||||
if (sessionId) {
|
||||
await this.context.workspaceState.update(
|
||||
webShellSessionSourceStateKey(data.workspaceCwd, sessionId),
|
||||
this.webShellSessionHistorySource,
|
||||
);
|
||||
await this.context.workspaceState.update(sessionKey, sessionId);
|
||||
} else {
|
||||
await this.context.workspaceState.update(sessionKey, undefined);
|
||||
}
|
||||
if (previousSessionId && previousSessionId !== sessionId) {
|
||||
await this.context.workspaceState
|
||||
.update(
|
||||
webShellSessionSourceStateKey(
|
||||
data.workspaceCwd,
|
||||
previousSessionId,
|
||||
),
|
||||
undefined,
|
||||
)
|
||||
.then(
|
||||
() => undefined,
|
||||
(error: unknown) => {
|
||||
logger.warn(
|
||||
'[WebViewProvider] Failed to retire web-shell session source state:',
|
||||
error,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -2041,29 +2000,6 @@ export class WebViewProvider {
|
||||
),
|
||||
canonicalWorkspaceCwd,
|
||||
);
|
||||
// Pre-cutover companions recorded their conversations in globalState;
|
||||
// their daemon transcripts carry no source attribution, so the
|
||||
// vscode-scoped history query cannot surface them. Ship the legacy ids
|
||||
// as an allowlist so the panel can claim its own sessions back from
|
||||
// the daemon's unattributed catalog. Read-only: the store stays
|
||||
// untouched for downgrade/recovery, and only ids cross the bridge —
|
||||
// never the message snapshots.
|
||||
let legacyConversationIds: string[] | undefined;
|
||||
try {
|
||||
const legacyIds = (await this.conversationStore.getAllConversations())
|
||||
.map((conversation) =>
|
||||
getRestorableDaemonSessionId(conversation.id),
|
||||
)
|
||||
.filter((id): id is string => id !== undefined);
|
||||
if (legacyIds.length > 0) {
|
||||
legacyConversationIds = legacyIds;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
'[WebViewProvider] Failed to read legacy conversations:',
|
||||
error,
|
||||
);
|
||||
}
|
||||
const serializedSessionId = getRestorableDaemonSessionId(
|
||||
this.messageHandler.getCurrentConversationId(),
|
||||
);
|
||||
@@ -2136,20 +2072,6 @@ export class WebViewProvider {
|
||||
const restoredSessionId = this.isViewHost
|
||||
? viewSessionId
|
||||
: serializedSessionId;
|
||||
const storedSessionSource =
|
||||
this.isViewHost && restoredSessionId
|
||||
? this.context.workspaceState.get<unknown>(
|
||||
webShellSessionSourceStateKey(
|
||||
canonicalWorkspaceCwd,
|
||||
restoredSessionId,
|
||||
),
|
||||
)
|
||||
: undefined;
|
||||
const restoredSessionHistorySource = this.isViewHost
|
||||
? storedSessionSource === 'default'
|
||||
? 'default'
|
||||
: 'vscode'
|
||||
: this.webShellSessionHistorySource;
|
||||
await webview.postMessage({
|
||||
type: 'webShellBootstrap',
|
||||
data: {
|
||||
@@ -2167,10 +2089,6 @@ export class WebViewProvider {
|
||||
: {}),
|
||||
hostKind: this.isViewHost ? 'view' : 'panel',
|
||||
...(restoredSessionId ? { sessionId: restoredSessionId } : {}),
|
||||
...(restoredSessionId && restoredSessionHistorySource
|
||||
? { sessionHistorySource: restoredSessionHistorySource }
|
||||
: {}),
|
||||
...(legacyConversationIds ? { legacyConversationIds } : {}),
|
||||
},
|
||||
});
|
||||
// A daemon that dies after a successful start — or that gets
|
||||
|
||||
@@ -6,11 +6,5 @@
|
||||
|
||||
/**
|
||||
* Creator attribution the companion stamps on every session it starts.
|
||||
*
|
||||
* The daemon is shared: the CLI, the browser Web Shell, and this extension all
|
||||
* talk to the same `qwen serve` instance for a workspace, and Web Shell
|
||||
* otherwise records `'default'` for every surface. Without a distinct value the
|
||||
* VS Code channel is indistinguishable from a terminal or browser session, so
|
||||
* the panel's history would list conversations the user never started here.
|
||||
*/
|
||||
export const VSCODE_SESSION_SOURCE_TYPE = 'vscode';
|
||||
@@ -30,9 +30,6 @@ const EN = {
|
||||
'session.searchPlaceholder': 'Search sessions…',
|
||||
'session.searchLabel': 'Search conversations',
|
||||
'session.listLabel': 'Conversations',
|
||||
'session.sourceLabel': 'Conversation source',
|
||||
'session.source.vscode': 'VS Code',
|
||||
'session.source.default': 'Terminal / Web',
|
||||
'session.closeHistory': 'Close conversation history',
|
||||
'session.rename': 'Rename',
|
||||
'session.renameLabel': 'Rename conversation',
|
||||
@@ -43,6 +40,8 @@ const EN = {
|
||||
'session.empty': 'No sessions available',
|
||||
'session.emptyFiltered': 'No matching sessions',
|
||||
'session.loading': 'Loading…',
|
||||
'session.loadMore': 'Load more',
|
||||
'session.historyIncomplete': 'Some conversations could not be loaded.',
|
||||
'askUser.other': 'Other...',
|
||||
'session.loadFailed': 'Failed to load sessions.',
|
||||
'session.renameFailed': 'Failed to rename session.',
|
||||
@@ -116,9 +115,6 @@ const ZH: Record<ChromeStringKey, string> = {
|
||||
'session.searchPlaceholder': '搜索会话…',
|
||||
'session.searchLabel': '搜索会话',
|
||||
'session.listLabel': '会话列表',
|
||||
'session.sourceLabel': '会话来源',
|
||||
'session.source.vscode': 'VS Code',
|
||||
'session.source.default': '终端 / Web',
|
||||
'session.closeHistory': '关闭历史会话',
|
||||
'session.rename': '重命名',
|
||||
'session.renameLabel': '重命名会话',
|
||||
@@ -129,6 +125,8 @@ const ZH: Record<ChromeStringKey, string> = {
|
||||
'session.empty': '暂无会话',
|
||||
'session.emptyFiltered': '没有匹配的会话',
|
||||
'session.loading': '加载中…',
|
||||
'session.loadMore': '加载更多',
|
||||
'session.historyIncomplete': '部分会话未能加载。',
|
||||
'askUser.other': '其他...',
|
||||
'session.loadFailed': '加载会话列表失败。',
|
||||
'session.renameFailed': '重命名会话失败。',
|
||||
|
||||
Reference in new issue
Block a user