mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
feat(desktop-fe): remote project tree and wizard polish / 远程项目树与向导打磨
This commit is contained in:
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,
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user