feat(desktop-fe): remote project tree and wizard polish / 远程项目树与向导打磨

This commit is contained in:
XTLine committed 2026-08-21 15:16:42 +08:00
1 parent 5278ec36ed
commit d597b10fd0
10 files changed
+184 -21

No files matched your search

@@ -447,7 +447,7 @@ ok(
/const controllerReady =\s*state\.meta\?\.ready === true &&\s*\(!state\.meta\.runtime \|\| state\.meta\.runtime\.phase === "ready"\) &&\s*!state\.meta\.startupErr &&\s*!state\.backendActivationPending &&\s*!runtimeTransitioning;/.test(appSource) &&
/if \(!activeTabId \|\| !controllerReady\) return;\s*void commitThenSend\(activeTabId, text\)\.catch/.test(appSource) &&
/onPrompt=\{handleTranscriptPrompt\}/.test(appSource) &&
/submitDisabled=\{!controllerReady\}/.test(appSource),
/submitDisabled=\{remoteSurfaceActive \? !remoteComposerReady : !controllerReady\}/.test(appSource),
"welcome prompts and composer submit share the controller readiness gate",
);
@@ -158,5 +158,17 @@ eq(archivedError[0]?.kind === "tool" && archivedError[0].status, "error", "archi
eq(archivedError[0]?.kind === "tool" && archivedError[0].dataArchived, true, "archived failed result is still loadable on demand");
eq(archivedError[0]?.kind === "tool" && archivedError[0].error, "error: permission denied", "archived failed result keeps bounded error preview");
const localOnlyDupes = toolItems([
{ role: "user", content: "first" },
{ role: "tool", toolCallId: "__reasonix_local_only__", toolName: "__reasonix_local_only__", content: "partial one" },
{ role: "user", content: "second" },
{ role: "tool", toolCallId: "__reasonix_local_only__", toolName: "__reasonix_local_only__", content: "partial two" },
] as HistoryMessage[]);
eq(localOnlyDupes.length, 2, "each interrupted-turn local-only record hydrates as its own tool item");
eq(
localOnlyDupes[0]?.id !== localOnlyDupes[1]?.id,
true,
"repeated __reasonix_local_only__ toolCallIds uniquify so transcript row keys cannot collide after restart",
);
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
@@ -4,6 +4,10 @@ import React from "react";
import { JSDOM } from "jsdom";
import { act } from "react";
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import type { AppBindings } from "../lib/bridge";
import type { RemoteHostView } from "../lib/types";
@@ -353,6 +357,24 @@ ok(tape.some((entry) => entry.startsWith("AddRemoteHost:10.9.8.7:10.9.8.7")), "a
ok(lastAddInput?.credentialMode === "local-proxy", `AddRemoteHost carries the chosen credential mode (got ${lastAddInput?.credentialMode})`);
await act(async () => secondRoot.unmount());
// ── Merged finish: source contract for overlapping workspaces ──
const here = dirname(fileURLToPath(import.meta.url));
const wizardSource = readFileSync(resolve(here, "../components/RemoteConnectWizard.tsx"), "utf8");
ok(
/const canonical = project\.merged \? project\.workspace : target;/.test(wizardSource) &&
/OpenRemoteProjectTab\(hostId, canonical, \{ newSession: true \}\)/.test(wizardSource),
"a merged finish opens the tab on the canonical workspace",
);
ok(
/if \(!project\.merged\) \{[\s\S]*?RemoveRemoteProject\(hostId, target\)/.test(wizardSource),
"rollback only removes a pin the wizard actually added (a merge owns none)",
);
ok(
/onMerged\?\.\(t\("remoteWizard\.mergedProject"/.test(wizardSource),
"a merged finish notifies through onMerged",
);
dom.window.close();
process.stdout.write(`\n${passed} passed, ${failed} failed\n`);
if (failed > 0) process.exit(1);
@@ -83,13 +83,33 @@ ok(
);
ok(
/project-tree__remote-badge--\$\{remoteStatuses\[node\.remote\.hostId\]\?\.state/.test(source),
"the group row badge reflects the live host status",
/project-tree__remote-badge--\$\{remoteServeBadgeState\(remoteServers\[node\.remote\.hostId\]\?\.\[node\.remote\.workspace\]\)\}/.test(source),
"the group row badge reflects the workspace's own serve state",
);
ok(
/case "ready":\s*\n\s*return "serve-ready";/.test(source) &&
/case "error":\s*\n\s*return "serve-error";/.test(source) &&
/serve-idle/.test(source),
"badge states map ready/busy/error/idle per serve",
);
ok(
/onSend=\{remoteSurfaceActive \? remoteSend : handleSend\}/.test(appSource),
"the shared Composer sends through the remote session on remote tabs",
);
ok(
/if \(remoteSurfaceActive && activeTabId\) \{[\s\S]*?await app\.SetRemoteTabModel\(activeTabId, name\)/.test(appSource),
"the shared Composer model switcher posts SetRemoteTabModel on remote tabs",
);
ok(
/if \(!controllerReady \|\| !activeTabId \|\| remoteSurfaceActive\) return;/.test(appSource),
"composer-profile apply skips remote tabs so it cannot toast tab is no longer available",
);
ok(
/submitDisabled=\{remoteSurfaceActive \? !remoteComposerReady : !controllerReady\}/.test(appSource) &&
/ready=\{remoteSurfaceActive \? remoteComposerReady : controllerReady\}/.test(appSource) &&
/const remoteComposerReady = remoteSurfaceActive && remoteSession\.state === "ready";/.test(appSource),
"the shared Composer send button uses remote serve ready, not the local controller",
);
ok(
/mockRemoteProjects: RemoteProjectView\[\] = \[\s*\/\/ Demo preseed[\s\S]*?hostId: "demo"/.test(bridgeSource),
"the mock preseeds a demo remote project",
@@ -309,6 +309,18 @@ const keys = (rows: TranscriptRow[]) => rows.map((row) => row.key).join(",");
eq(historyEntryIdForRow({ kind: "older-history", key: "older-history" }), undefined, "the paging row has no entry");
}
{
const localOnly: Item[] = [
{ kind: "user", id: "u1", text: "first" },
{ kind: "tool", id: "__reasonix_local_only__", name: "__reasonix_local_only__", args: "", status: "done", output: "partial one" },
{ kind: "user", id: "u2", text: "second" },
{ kind: "tool", id: "__reasonix_local_only__", name: "__reasonix_local_only__", args: "", status: "done", output: "partial two" },
];
const rows = buildTranscriptRows(buildTurnModels(localOnly), rowOptions(EMPTY_FOLDS, "expanded"));
const rowKeys = rows.map((row) => row.key);
eq(new Set(rowKeys).size, rowKeys.length, "two local-only recoveries cannot share ph:/t: row keys");
}
// ── Size estimates ────────────────────────────────────────────────────────────
{
@@ -12,7 +12,7 @@ import { app } from "../lib/bridge";
import { onProjectTreeChangedV2 } from "../lib/sessionCatalogBridge";
import { isRuntimeSessionNode, isTopicNode, loadWorkbenchOrganizeMode, loadWorkbenchSortMode, mergeProjectTopicPage, projectTreeDedupedExactTime, projectTreeEventAffectsFolder, projectTreeFolderDisclosure, projectTreeReadActivityKey, projectTreeRevisionIsFresh, projectTreeShellChildren, projectTreeShellSignature, projectTreeShouldApplyShellSnapshot, projectTreeShouldRenderTopicActions, projectTreeShouldSuppressOpenForRename, projectTreeTopicArchiveBlocked, projectTreeTopicHasUnreadActivity, projectTreeTopicHoverCardModel, projectTreeTopicMenuOffersPin, projectTreeTopicMetaLine, projectTreeTopicOpenRequest, projectTreeTopicPageIsFresh, projectTreeWithoutTopic, topicActivityAt, topicActivityDateLabel, topicActivityLabel, topicIsActive, topicStatus, topicStatusLabel, topicUnknownTimeLabel, WORKBENCH_ORGANIZE_KEY, WORKBENCH_SORT_KEY, type ProjectTreePendingTopicOpen, type ProjectTreeReadActivity, type ProjectTreeTopicHoverCard, type ProjectTreeVariant, type WorkbenchOrganizeMode, type WorkbenchSortMode } from "../lib/projectTreeTopic";
export * from "../lib/projectTreeTopic";
import type { ProjectNode, RemoteSessionView, RemoteTabRefView, SessionCatalogStatus } from "../lib/types";
import type { ProjectNode, RemoteServerView, RemoteSessionView, RemoteTabRefView, SessionCatalogStatus } from "../lib/types";
import { useRemoteStore } from "../store/remote";
import { onRemoteTabOpened } from "../lib/bridge";
import { topicActivityTime } from "../lib/session";
@@ -61,6 +61,23 @@ function projectNodeKey(node: ProjectNode, depth: number): string {
return node.key || `${node.kind}-${node.root ?? ""}-${node.topicId ?? ""}-${depth}`;
}
// The group dot reports the workspace's OWN serve: green only when that
// serve is actually ready, amber while it boots/installs, red on error, grey
// when it has never been woken. Host connectivity stays on the hosts surface.
function remoteServeBadgeState(view?: RemoteServerView): string {
switch (view?.state) {
case "ready":
return "serve-ready";
case "error":
return "serve-error";
case "stopped":
case undefined:
return "serve-idle";
default:
return "serve-busy"; // starting + install/launch progress states
}
}
type ProjectDropPosition = "before" | "after";
type WorkbenchHeaderMenu = "more" | "add" | null;
@@ -608,9 +625,10 @@ export function ProjectTree({
}
}, [showToast]);
// Remote group badges follow the live host status; statuses hydrate once
// on mount so badges are correct without a wizard run first.
const remoteStatuses = useRemoteStore((s) => s.statuses);
// Remote group badges follow the workspace's own serve state; the registry
// hydrates once per group set and live updates arrive through remote:server
// events (wired in App).
const remoteServers = useRemoteStore((s) => s.servers);
const remoteStatusesSeededRef = useRef(false);
useEffect(() => {
if (remoteStatusesSeededRef.current) return;
@@ -637,12 +655,35 @@ export function ProjectTree({
}
return keys;
}, [tree]);
// Serve-state hydration is a local registry read (never wakes anything);
// it seeds the dots before any remote:server event fires this session.
useEffect(() => {
for (const groupKey of remoteGroupKeys) {
const [hostId, workspace] = groupKey.split("\u0000");
void app.RemoteServerStatus(hostId, workspace)
.then((view) => useRemoteStore.getState().setServer(view))
.catch(() => {});
}
}, [remoteGroupKeys]);
// Layer-2 on-demand listing: a group's sessions are fetched only while the
// group is expanded (or the tree search is active) and re-pulled on the
// refresh signals above — never speculatively for every pinned group. The
// backend resolves read-only, so an unvisited group costs nothing.
const remoteGroupNodeKeys = useMemo(() => {
const map = new Map<string, string>();
for (const node of tree) {
if (node.remote) map.set(`${node.remote.hostId}\u0000${node.remote.workspace}`, projectNodeKey(node, 0));
}
return map;
}, [tree]);
const remoteSearchActive = query.trim() !== "";
useEffect(() => {
let cancelled = false;
for (const groupKey of remoteGroupKeys) {
const nodeKey = remoteGroupNodeKeys.get(groupKey);
if (!nodeKey) continue;
if (!expanded.has(nodeKey) && !remoteSearchActive) continue;
const [hostId, workspace] = groupKey.split("\u0000");
const state = remoteStatuses[hostId]?.state;
if (state !== "connected" && state !== "degraded") continue;
void app.RemoteProjectSessions(hostId, workspace)
.then((rows) => {
if (!cancelled) setRemoteSessions((current) => ({ ...current, [groupKey]: rows }));
@@ -650,7 +691,7 @@ export function ProjectTree({
.catch(() => {});
}
return () => { cancelled = true; };
}, [remoteGroupKeys, remoteStatuses, remoteSessionsRevision]);
}, [remoteGroupKeys, remoteGroupNodeKeys, expanded, remoteSearchActive, remoteSessionsRevision]);
// topicId → remote session identity for the synthesized rows; rebuilt
// during render so the topic-branch action functions can route remotely.
@@ -2043,7 +2084,7 @@ export function ProjectTree({
<span className={`project-tree__folder-label${!hasChildren ? " project-tree__folder-label--empty" : ""}`}>
{projectLabel}
{node.isolatedWorktree && <WorktreeBadge size={11} />}
{node.remote ? <span className={`project-tree__remote-badge project-tree__remote-badge--${remoteStatuses[node.remote.hostId]?.state ?? "disconnected"}`} aria-hidden="true" /> : null}
{node.remote ? <span className={`project-tree__remote-badge project-tree__remote-badge--${remoteServeBadgeState(remoteServers[node.remote.hostId]?.[node.remote.workspace])}`} aria-hidden="true" /> : null}
</span>
</button>
{compactTopics && (
@@ -58,9 +58,12 @@ function parentOf(path: string): string {
export function RemoteConnectWizard({
onRefresh,
onClose,
onMerged,
}: {
onRefresh: () => Promise<void>;
onClose: () => void;
/** Fired with a pre-translated notice when the chosen path merged into an existing pinned group. */
onMerged?: (message: string) => void;
}) {
const t = useT();
const hosts = useRemoteStore((s) => s.hosts);
@@ -241,23 +244,34 @@ export function RemoteConnectWizard({
setBusy(true);
setError("");
try {
let project: Awaited<ReturnType<typeof app.AddRemoteProject>> | null = null;
try {
await app.AddRemoteProject(hostId, target);
project = await app.AddRemoteProject(hostId, target);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
return;
}
// The registry collapses overlapping paths: the canonical workspace is
// where the tab must land, and a merge means no new pin exists to roll
// back — removing it would delete the user's existing group.
const canonical = project.merged ? project.workspace : target;
if (project.merged) {
onMerged?.(t("remoteWizard.mergedProject", { path: canonical }));
}
try {
await app.OpenRemoteProjectTab(hostId, target, { newSession: true });
await app.OpenRemoteProjectTab(hostId, canonical, { newSession: true });
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
// All-or-nothing: a failed open rolls the just-registered project
// back so no half-applied pin survives. If the rollback itself
// fails, the pin is on disk — refresh so the tree stays honest.
try {
await app.RemoveRemoteProject(hostId, target);
} catch {
await onRefresh().catch(() => {});
// back so no half-applied pin survives. A merged finish added no pin,
// so there is nothing to remove. If the rollback itself fails, the
// pin is on disk — refresh so the tree stays honest.
if (!project.merged) {
try {
await app.RemoveRemoteProject(hostId, target);
} catch {
await onRefresh().catch(() => {});
}
}
return;
}
@@ -54,6 +54,7 @@ export function useProjectCreation({
<RemoteConnectWizard
onRefresh={onRefresh}
onClose={() => setRemoteFlowOpen(false)}
onMerged={(message) => showToast(message, "info")}
/>
</Suspense>
) : null,
+30 -2
View File
@@ -226,6 +226,19 @@ export function buildTurnModels(
}
}
flush();
const usedSegmentKeys = new Set<string>();
const uniqueSegmentKey = (base: string): string => {
let key = base || "seg";
if (!usedSegmentKeys.has(key)) {
usedSegmentKeys.add(key);
return key;
}
let n = 1;
while (usedSegmentKeys.has(`${base || "seg"}#${n}`)) n += 1;
key = `${base || "seg"}#${n}`;
usedSegmentKeys.add(key);
return key;
};
for (let index = 0; index < turns.length; index += 1) {
const model = turns[index];
@@ -237,7 +250,7 @@ export function buildTurnModels(
const displayItems = foldDisplayItems(segment.processItems, live, hideReasoning);
const turnActive = model.isActive && isLastSegment;
return {
key: segment.processItems[0]?.id ?? "",
key: uniqueSegmentKey(segment.processItems[0]?.id ?? ""),
processItems: segment.processItems,
outsideItems: segment.outsideItems,
displayItems,
@@ -546,7 +559,22 @@ export function buildTranscriptRows(models: readonly TurnModel[], options: Build
rows.push({ kind: "turn-actions", key: `ta:${user.id}`, turn, text: model.actionText });
}
}
return rows;
return uniquifyTranscriptRowKeys(rows);
}
function uniquifyTranscriptRowKeys(rows: TranscriptRow[]): TranscriptRow[] {
const used = new Set<string>();
return rows.map((row) => {
if (!used.has(row.key)) {
used.add(row.key);
return row;
}
let n = 1;
while (used.has(`${row.key}#${n}`)) n += 1;
const key = `${row.key}#${n}`;
used.add(key);
return { ...row, key };
});
}
// ── Live turn split ───────────────────────────────────────────────────────────
+13
View File
@@ -38565,3 +38565,16 @@ body > .mermaid-diagram--fullscreen {
.project-tree__remote-badge--error {
background: var(--danger, var(--fg));
}
/* Per-workspace serve state: green only when that group's serve is ready. */
.project-tree__remote-badge--serve-ready {
background: var(--ok);
}
.project-tree__remote-badge--serve-busy {
background: var(--warn, var(--fg));
}
.project-tree__remote-badge--serve-error {
background: var(--danger, var(--fg));
}
.project-tree__remote-badge--serve-idle {
background: var(--fg-faint);
}