Skip to content

Commit c1c00cb

Browse files
authored
refactor(web-shell): simplify remote workspace flow (#12264)
1 parent 62e8994 commit c1c00cb

16 files changed

Lines changed: 73 additions & 129 deletions

‎docs/design/remote-web-shell-daemon.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ The standalone Web Shell reads the `daemon` query parameter and passes that orig
3131

3232
The pre-connection gate always exposes a daemon address and optional token form, including when the URL contains an invalid target. Once connected, the existing Daemon Status overview shows the current target and connection state and provides the same switch controls. Switching performs a full page navigation, clears the selected session, workspace, and context from the URL, and creates a fresh SDK client for the new daemon. Reconnecting to the target already in use reloads in place instead, so the selected session, workspace, and context survive it exactly as they survive a plain refresh. It does not probe or fall back to another runtime.
3333

34-
The existing sidebar remains the workspace and session management UI. Settings includes a **Connections** category where remote computers are added, reviewed, forgotten, or selected. Adding a cross-origin computer temporarily navigates to that daemon so the existing connection gate can verify its capabilities and credential without weakening CSP; success or cancellation returns to the source shell with **Settings > Connections** reopened. A successful remote connection records only its validated origin in a browser-local connection catalog; bearer tokens remain tab-scoped, and forgetting a connection removes its tab-scoped credential too. The normal **Add workspace** action opens the directory browser directly. Its **Folder source** selector lists this computer and the connected remote computers, matching the source-selection pattern used by Codex project creation without adding a separate Local/Remote step. Selecting a computer this tab is not already connected to navigates to that daemon and resumes the same directory browser; selecting the daemon already in use keeps it open in place without reloading the shell. The browser uses daemon-provided directory suggestions, supports parent-directory navigation and manual absolute paths, and registers the selected directory through the existing workspace mutation. Native folder selection remains hidden for remote daemons. A cross-origin daemon is named once as a host chip beside the sidebar's Project heading, and each remote workspace row uses a folder icon with a small blue globe so local and remote folders remain visually distinct. Session discovery, transcript loading, file references, terminal traffic, and execution require no parallel remote-specific implementations because they already use the selected SDK client.
34+
The existing sidebar remains the workspace and session management UI. Settings includes a **Connections** category where remote computers are added, reviewed, forgotten, or selected. Adding a cross-origin computer temporarily navigates to that daemon so the existing connection gate can verify its capabilities and credential without weakening CSP; success or cancellation returns to the source shell with **Settings > Connections** reopened. Submitting an explicit connection form, from either **Settings > Connections** or Daemon Status, records the validated origin in a browser-local connection catalog; bearer tokens remain tab-scoped, and forgetting a connection removes its tab-scoped credential too. The normal **Add workspace** action opens the directory browser directly. Its **Folder source** selector lists this computer and the connected remote computers, matching the source-selection pattern used by Codex project creation without adding a separate Local/Remote step. Selecting a computer this tab is not already connected to navigates to that daemon and resumes the same directory browser; selecting the daemon already in use keeps it open in place without reloading the shell. The browser uses daemon-provided directory suggestions, supports parent-directory navigation and manual absolute paths, and registers the selected directory through the existing workspace mutation. Native folder selection remains hidden for remote daemons. Each remote workspace row uses a folder icon with a small blue globe so local and remote folders remain visually distinct. Session discovery, transcript loading, file references, terminal traffic, and execution require no parallel remote-specific implementations because they already use the selected SDK client.
3535

3636
The add operation remains a one-shot flow. The source URL is kept only in the current tab while navigation is in progress. Cancel returns to that URL, changing **Folder source** continues the same browser on the selected computer, and a successful registration stays on the selected daemon and clears the continuation state. The connection catalog stores origins only; it does not cache remote workspaces or aggregate projects from multiple daemons. Ordinary daemon switches do not resume the flow.
3737

@@ -43,7 +43,7 @@ Disconnecting or closing the browser only disposes the client connection. It doe
4343

4444
## Failure and Security Boundaries
4545

46-
- An unfamiliar `?daemon=` target waits for explicit confirmation before any probe. A target is added to the persistent connection catalog only after its capabilities probe succeeds.
46+
- An unfamiliar `?daemon=` target waits for explicit confirmation before any probe. Explicit connection forms write the target to the persistent catalog only after its capabilities probe succeeds; opening an ordinary daemon URL remains tab-scoped.
4747
- The browser-local file bridge is offered only when the connected daemon is the page's own origin, in the standalone and embedded shells alike: a cross-origin target never mounts it, so a client directory cannot be handed to a remote daemon whose panel copy promises files stay on the computer. Remote workspace files remain available through the selected daemon. The same-origin SSH-tunnel deployment keeps its behavior and origin-scoped grants.
4848
- The remote-add continuation is explicit, tab-scoped, and one-shot. It reuses the origin-only connection catalog but does not persist a project catalog, aggregate workspaces from multiple daemons, or alter ordinary daemon switching.
4949

‎docs/design/remote-web-shell-daemon.zh-CN.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ Web Shell 已经通过同一个 daemon `baseUrl` 发送 workspace、session、
3131

3232
连接前页面始终提供 daemon 地址和可选 token 表单,包括 URL 中目标无效的情况。连接成功后,现有 Daemon 状态概览会显示当前目标和连接状态,并提供相同的切换控件。切换目标时执行完整页面导航,清除 URL 中已选的 session、workspace 和 context,并为新 daemon 创建全新的 SDK client。重连到当前正在使用的目标时改为原地重新加载,因此已选的 session、workspace 和 context 会像普通刷新一样原样保留。此过程不会探测或回退到其他 runtime。
3333

34-
现有侧边栏继续作为 workspace 和 session 管理界面。设置中新增“连接”分类,用于添加、查看、移除或选择远程计算机。添加跨 origin 计算机时会临时导航到对应 daemon,复用现有连接页验证 capabilities 和凭据而不放宽 CSP;验证成功或取消后都会返回来源 shell,并重新打开“设置 > 连接”。远程连接验证成功后,仅把其 origin 记录到浏览器本地的连接目录中,bearer token 仍限定在当前标签页;移除连接时也会同时清理该 origin 在当前标签页中的凭据。普通的“添加工作区”操作直接打开目录浏览器;浏览器顶部的“目录来源”可以选择这台计算机或一台已连接的远程计算机,参考 Codex 创建项目时的来源选择方式,不再增加独立的“本地/远程”步骤。选择当前标签页尚未连接的计算机时导航到对应 daemon,再续接同一个目录浏览流程;选择当前已连接的 daemon 则保持原地打开,不重新加载 shell。目录浏览使用 daemon 返回的目录建议,支持进入上级目录和手工填写绝对路径,并通过现有 workspace mutation 注册所选目录;连接远程 daemon 时继续隐藏原生目录选择器。跨 origin daemon 会在侧边栏“项目”标题旁显示主机名 chip;每个远程 workspace 行还会使用带蓝色小地球的文件夹图标,让本地和远程目录在视觉上保持可区分。session 发现、对话记录加载、文件引用、终端流量和执行不需要再实现一套远程专用逻辑,因为它们已经统一使用所选 SDK client。
34+
现有侧边栏继续作为 workspace 和 session 管理界面。设置中新增“连接”分类,用于添加、查看、移除或选择远程计算机。添加跨 origin 计算机时会临时导航到对应 daemon,复用现有连接页验证 capabilities 和凭据而不放宽 CSP;验证成功或取消后都会返回来源 shell,并重新打开“设置 > 连接”。无论来自“设置 > 连接”还是 Daemon 状态,只要显式提交连接表单,就会把验证后的 origin 记录到浏览器本地的连接目录中;bearer token 仍限定在当前标签页,移除连接时也会同时清理该 origin 在当前标签页中的凭据。普通的“添加工作区”操作直接打开目录浏览器;浏览器顶部的“目录来源”可以选择这台计算机或一台已连接的远程计算机,参考 Codex 创建项目时的来源选择方式,不再增加独立的“本地/远程”步骤。选择当前标签页尚未连接的计算机时导航到对应 daemon,再续接同一个目录浏览流程;选择当前已连接的 daemon 则保持原地打开,不重新加载 shell。目录浏览使用 daemon 返回的目录建议,支持进入上级目录和手工填写绝对路径,并通过现有 workspace mutation 注册所选目录;连接远程 daemon 时继续隐藏原生目录选择器。每个远程 workspace 行使用带蓝色小地球的文件夹图标,让本地和远程目录在视觉上保持可区分。session 发现、对话记录加载、文件引用、终端流量和执行不需要再实现一套远程专用逻辑,因为它们已经统一使用所选 SDK client。
3535

3636
添加操作仍是一次性流程。导航期间只在当前标签页保存来源 URL;取消会返回该 URL,切换“目录来源”会在所选计算机上续接同一个目录浏览器,注册成功后留在所选 daemon 并清除续接状态。连接目录只保存 origin,不缓存远程 workspace,也不聚合多个 daemon 的项目。普通 daemon 切换不会续接该流程。
3737

@@ -43,7 +43,7 @@ Bearer token 仍保存在当前标签页的 `sessionStorage` 中,但存储键
4343

4444
## 失败与安全边界
4545

46-
- 不熟悉的 `?daemon=` 目标必须先明确确认,再发起探测。只有 capabilities 探测成功后,目标才会加入持久化连接目录。
46+
- 不熟悉的 `?daemon=` 目标必须先明确确认,再发起探测。显式连接表单只会在 capabilities 探测成功后把目标写入持久化连接目录;直接打开普通 daemon URL 仍只在当前标签页生效。
4747
- 浏览器本地文件桥仅在所连接的 daemon 与页面同源时提供,独立与嵌入式外壳一致:跨来源目标不会挂载文件桥,本地目录不会被交给一个文案承诺「文件留在你的电脑上」的远程 daemon。远端工作区文件仍由选中的 daemon 提供。同源的 SSH 隧道部署保留原行为和按来源隔离的授权。
4848
- 远程添加续接必须显式触发,只在当前标签页生效,并且仅使用一次。它会复用只含 origin 的连接目录,但不会持久化项目目录、不会聚合多个 daemon 的 workspace,也不会改变普通 daemon 切换行为。
4949

‎packages/web-shell/client/App.tsx‎

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -267,7 +267,7 @@ import {
267267
clearRemoteWorkspaceAddStep,
268268
completeRemoteWorkspaceAdd,
269269
discardAbandonedRemoteWorkspaceAdd,
270-
getRemoteWorkspaceAddStep,
270+
isRemoteWorkspaceAddActive,
271271
leaveRemoteWorkspaceAdd,
272272
selectRemoteWorkspaceLocation,
273273
} from './config/remote-workspace-add';
@@ -3669,25 +3669,23 @@ export function App({
36693669
true;
36703670
const gitHubPrsSupported =
36713671
workspace.capabilities?.features?.includes('workspace_github_prs') === true;
3672-
const [initialRemoteWorkspaceAddStep] = useState(() =>
3673-
standalone ? getRemoteWorkspaceAddStep() : undefined,
3672+
const [initialRemoteWorkspaceAddActive] = useState(
3673+
() => standalone && isRemoteWorkspaceAddActive(),
36743674
);
36753675
const [showAddWorkspaceDialog, setShowAddWorkspaceDialog] = useState(
3676-
initialRemoteWorkspaceAddStep === 'browse',
3676+
initialRemoteWorkspaceAddActive,
36773677
);
36783678
// Browsing the daemon's folders, whether this tab navigated here for it or
36793679
// opened the browser in place.
3680-
const workspaceBrowseActiveRef = useRef(
3681-
initialRemoteWorkspaceAddStep === 'browse',
3682-
);
3680+
const workspaceBrowseActiveRef = useRef(initialRemoteWorkspaceAddActive);
36833681
useEffect(() => {
36843682
// No marker on a standalone boot means the hand-over that wrote the return
36853683
// location was abandoned (reload or Back), not resumed. Dropping it here
36863684
// keeps a later Cancel in an unrelated Add-workspace dialog from consuming
36873685
// the stale location and navigating the shell away.
3688-
if (initialRemoteWorkspaceAddStep) clearRemoteWorkspaceAddStep();
3686+
if (initialRemoteWorkspaceAddActive) clearRemoteWorkspaceAddStep();
36893687
else if (standalone) discardAbandonedRemoteWorkspaceAdd();
3690-
}, [initialRemoteWorkspaceAddStep, standalone]);
3688+
}, [initialRemoteWorkspaceAddActive, standalone]);
36913689
const [workspaceMutationBusy, setWorkspaceMutationBusy] = useState(false);
36923690
const workspaceMutationTokenRef = useRef<symbol | null>(null);
36933691
const workspaceSwitchTokenRef = useRef<symbol | null>(null);

‎packages/web-shell/client/components/StandaloneAuth.test.tsx‎

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,7 @@ async function mount(
3535
daemonOrigin: string,
3636
token?: string,
3737
options?: {
38-
continueRemoteWorkspaceAdd?: boolean;
39-
continueRemoteConnectionAdd?: boolean;
38+
continueFlow?: 'workspace' | 'connection';
4039
},
4140
) => boolean | void,
4241
) {
@@ -106,7 +105,7 @@ async function submitForm() {
106105
.querySelector('form')!
107106
.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
108107
}
109-
it('retries an invalid token and stores the accepted token per tab', async () => {
108+
it('retries with the typed token, stores it per tab, and creates no connection', async () => {
110109
const fetch = vi
111110
.fn()
112111
.mockResolvedValueOnce(stubResponse({ status: 401 }))
@@ -129,9 +128,7 @@ it('retries an invalid token and stores the accepted token per tab', async () =>
129128
expect(sessionStorage.getItem('qwen-daemon-token:http://daemon.test')).toBe(
130129
'good',
131130
);
132-
expect(
133-
JSON.parse(localStorage.getItem('qwen-remote-connections') || 'null'),
134-
).toEqual(['http://daemon.test']);
131+
expect(localStorage.getItem('qwen-remote-connections')).toBeNull();
135132
expect(fetch.mock.calls[1][1].headers).toEqual({
136133
Authorization: 'Bearer good',
137134
});
@@ -212,7 +209,7 @@ it('preserves remote-add continuation when correcting the daemon target', async
212209
expect(onChangeTarget).toHaveBeenCalledWith(
213210
'http://replacement.example:4170',
214211
undefined,
215-
{ continueRemoteWorkspaceAdd: true },
212+
{ continueFlow: 'workspace' },
216213
);
217214
});
218215
it('preserves connection-add verification when correcting the daemon target', async () => {
@@ -233,7 +230,7 @@ it('preserves connection-add verification when correcting the daemon target', as
233230
expect(onChangeTarget).toHaveBeenCalledWith(
234231
'http://replacement.example:4170',
235232
undefined,
236-
{ continueRemoteConnectionAdd: true },
233+
{ continueFlow: 'connection' },
237234
);
238235
});
239236
it('asks before probing a daemon this browser has not connected to', async () => {

‎packages/web-shell/client/components/StandaloneAuth.tsx‎

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,9 @@ import {
1717
completeRemoteConnectionAdd,
1818
isRemoteConnectionAddActive,
1919
leaveRemoteConnectionAdd,
20-
rememberRemoteConnection,
2120
} from '../config/remote-connections';
2221
import {
23-
getRemoteWorkspaceAddStep,
22+
isRemoteWorkspaceAddActive,
2423
leaveRemoteWorkspaceAdd,
2524
} from '../config/remote-workspace-add';
2625
import type { WebShellLanguage } from '../i18n';
@@ -175,14 +174,13 @@ export function StandaloneAuth({
175174
daemonOrigin: string,
176175
token?: string,
177176
options?: {
178-
continueRemoteWorkspaceAdd?: boolean;
179-
continueRemoteConnectionAdd?: boolean;
177+
continueFlow?: 'workspace' | 'connection';
180178
},
181179
) => boolean | void;
182180
children: (token: string | undefined) => ReactNode;
183181
}) {
184182
const copy = COPY[language] ?? COPY.en;
185-
const remoteWorkspaceAddActive = getRemoteWorkspaceAddStep() === 'browse';
183+
const remoteWorkspaceAddActive = isRemoteWorkspaceAddActive();
186184
const remoteConnectionAddActive = isRemoteConnectionAddActive();
187185
const [address, setAddress] = useState(initialAddress);
188186
const [token, setToken] = useState(initialToken ?? '');
@@ -265,7 +263,6 @@ export function StandaloneAuth({
265263
if (response.ok) {
266264
persistDaemonToken(candidate, baseUrl);
267265
confirmDaemonTarget(baseUrl);
268-
rememberRemoteConnection(baseUrl);
269266
if (
270267
remoteConnectionAddActive &&
271268
completeRemoteConnectionAdd(baseUrl)
@@ -398,11 +395,11 @@ export function StandaloneAuth({
398395
token.trim() || getDaemonToken(normalizedAddress);
399396
const switched = remoteWorkspaceAddActive
400397
? onChangeTarget(normalizedAddress, candidate, {
401-
continueRemoteWorkspaceAdd: true,
398+
continueFlow: 'workspace',
402399
})
403400
: remoteConnectionAddActive
404401
? onChangeTarget(normalizedAddress, candidate, {
405-
continueRemoteConnectionAdd: true,
402+
continueFlow: 'connection',
406403
})
407404
: onChangeTarget(normalizedAddress, candidate);
408405
if (switched === false) {

‎packages/web-shell/client/components/sidebar/WebShellSidebar.module.css‎

Lines changed: 0 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2062,32 +2062,6 @@
20622062
}
20632063
}
20642064

2065-
/* Registered workspace count next to the Projects label. */
2066-
.projectsHeaderRemote {
2067-
display: inline-flex;
2068-
align-items: center;
2069-
gap: 3px;
2070-
min-width: 0;
2071-
max-width: 140px;
2072-
height: 16px;
2073-
padding: 0 6px;
2074-
border-radius: 8px;
2075-
background: var(--sidebar-accent);
2076-
color: var(--agent-blue-500);
2077-
font-size: 11px;
2078-
font-weight: 500;
2079-
line-height: 1;
2080-
overflow: hidden;
2081-
text-overflow: ellipsis;
2082-
white-space: nowrap;
2083-
}
2084-
2085-
.projectsHeaderRemote svg {
2086-
width: 10px;
2087-
height: 10px;
2088-
flex: 0 0 10px;
2089-
}
2090-
20912065
.projectsHeaderCount {
20922066
display: inline-flex;
20932067
align-items: center;

‎packages/web-shell/client/components/sidebar/WebShellSidebar.tsx‎

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,6 @@ import {
4747
FolderInputIcon,
4848
GitBranchIcon,
4949
GitForkIcon,
50-
GlobeIcon,
5150
PencilIcon,
5251
PinIcon,
5352
Trash2Icon,
@@ -102,7 +101,6 @@ import {
102101
import { writeClipboardText } from '../../utils/clipboard';
103102
import { isDesktopShell } from '../../utils/externalOpen';
104103
import { isLocalDaemon, isPageOriginDaemon } from '../../config/daemon';
105-
import { formatOriginHost } from '../../config/remote-connections';
106104
import {
107105
mergeSessionContentHits,
108106
sessionMatchesGitQuery,
@@ -5802,21 +5800,6 @@ export function WebShellSidebar({
58025800
}}
58035801
>
58045802
<span>{t('sidebar.project')}</span>
5805-
{!isPageOriginDaemon(workspace.baseUrl) && (
5806-
// One daemon serves every workspace below, so the machine
5807-
// is named once here instead of badging each row with the
5808-
// same fact.
5809-
<span
5810-
className={styles.projectsHeaderRemote}
5811-
data-testid="remote-workspace-indicator"
5812-
title={t('sidebar.workspacesOnHost', {
5813-
host: formatOriginHost(workspace.baseUrl),
5814-
})}
5815-
>
5816-
<GlobeIcon aria-hidden="true" />
5817-
{formatOriginHost(workspace.baseUrl)}
5818-
</span>
5819-
)}
58205803
{workspaceOverviewEnabled && projectWorkspaces.length > 1 && (
58215804
<span
58225805
className={styles.projectsHeaderCount}

0 commit comments

Comments
 (0)