Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/PROJECT_MEMORY.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Prayu Project Memory

> 2026-10-09 recovery and connection workflow checkpoint (#292): Continuity
> Fork/Resume and checkpoint Fork open the returned Run in V2 without inheriting
> a source Thread binding. Late completion refreshes record and Thread lists
> without replacing a later navigation. GitHub connection editing uses the selected
> generation; confirmed disconnect deletes the connection's current local credential,
> preserving settings and historical evidence. Approval navigation retains the exact
> PR, snapshot and connection binding. Verification is recorded in the issue/PR; this slice
> does not add execution authority or certify the remaining frontend rebuild.

> 2026-10-09 Go security baseline (#289): Go 1.26.9 is the minimum compiler;
> CI and release builders read the exact version from go.mod. Local desktop
> builders require that same patched compiler. The x/net
Expand Down
22 changes: 15 additions & 7 deletions web/src/components/context-continuity-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import type {
} from "../api/types";
import { formatDate, shortID } from "../lib/format";
import { useLocale } from "../lib/locale";
import { useConnectionStore } from "../state/connection";
import { v2QueryKeys } from "../v2/query-keys";
import { ErrorState, LoadingState, StatusBadge } from "./common";

type MemoryScope = "project" | "user";
Expand All @@ -47,15 +47,15 @@ const emptyMemoryDraft: MemoryDraft = {
redactSensitive: false,
};

export function ContextContinuityPanel({ client, runID, sessionID, workspaceID }: {
export function ContextContinuityPanel({ client, runID, sessionID, workspaceID, onOpenRun }: {
client: APIClient;
runID: string;
sessionID: string;
workspaceID: string;
onOpenRun?: (runID: string) => void;
}) {
const { t } = useLocale();
const queryClient = useQueryClient();
const selectRun = useConnectionStore((state) => state.selectRun);
const [memoryScope, setMemoryScope] = useState<MemoryScope>(workspaceID ? "project" : "user");
const [memoryDraft, setMemoryDraft] = useState<MemoryDraft>(emptyMemoryDraft);
const [editingMemoryID, setEditingMemoryID] = useState("");
Expand Down Expand Up @@ -177,13 +177,21 @@ export function ContextContinuityPanel({ client, runID, sessionID, workspaceID }
`/continuity-nodes/${encodeURIComponent(node.id)}/${kind}`,
{ goal: branchGoal || undefined },
`web-continuity-${kind}-${globalThis.crypto.randomUUID()}`),
onSuccess: (result) => {
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["runs"] });
void queryClient.invalidateQueries({ queryKey: ["sessions"] });
selectRun(result.run.id);
void queryClient.invalidateQueries({ queryKey: v2QueryKeys.inspectorRecords });
void queryClient.invalidateQueries({ queryKey: v2QueryKeys.threads("active") });
void queryClient.invalidateQueries({ queryKey: v2QueryKeys.workspaces });
},
});

const branchFrom = (node: SessionTreeNodeView, kind: BranchKind) => {
// Observer callbacks stop on unmount; background completion still refreshes
// the records above, but must not take navigation away from another page.
branch.mutate({ node, kind }, { onSuccess: (result) => onOpenRun?.(result.run.id) });
};

const exportMemories = useMutation({
mutationFn: () => client.get<ContextMemoryExportView>("/memories/export", {
scope: memoryScope,
Expand Down Expand Up @@ -395,11 +403,11 @@ export function ContextContinuityPanel({ client, runID, sessionID, workspaceID }
</div>
{!node.derived && <div className="context-row-actions">
<button className="compact-command" disabled={!client.hasControl || branch.isPending}
onClick={() => branch.mutate({ node, kind: "fork" })} type="button">
onClick={() => branchFrom(node, "fork")} type="button">
<GitFork aria-hidden="true" size={12} />Fork
</button>
<button className="compact-command" disabled={!client.hasControl || branch.isPending}
onClick={() => branch.mutate({ node, kind: "resume" })} type="button">
onClick={() => branchFrom(node, "resume")} type="button">
<RefreshCw aria-hidden="true" size={12} />Resume
</button>
</div>}
Expand Down
504 changes: 478 additions & 26 deletions web/src/components/github-review-panel.test.tsx

Large diffs are not rendered by default.

493 changes: 402 additions & 91 deletions web/src/components/github-review-panel.tsx

Large diffs are not rendered by default.

8 changes: 5 additions & 3 deletions web/src/components/run-workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -227,10 +227,11 @@ export function RunWorkspaceTabs({ activeTab, ariaLabel, children, items, onSele
</>;
}

export function RunWorkspace({ client, runID, onOpenPlugins }: {
export function RunWorkspace({ client, runID, onOpenPlugins, onOpenRun }: {
client: APIClient;
runID: string;
onOpenPlugins?: () => void;
onOpenRun?: (runID: string) => void;
}) {
const { t } = useLocale();
const [tab, setTab] = useState<RunTab>("activity");
Expand Down Expand Up @@ -463,7 +464,7 @@ export function RunWorkspace({ client, runID, onOpenPlugins }: {
onOpenReceiptReview={openReceiptReview} />}
{tab === "receipts" && <OperationReceiptHistory client={client} runID={runID} />}
{tab === "checkpoints" && <WorkspaceCheckpointPanel client={client} runID={runID}
runStatus={detail.run.status} />}
runStatus={detail.run.status} onOpenRun={onOpenRun} />}
{tab === "agents" && <AgentGraphPanel client={client} runID={runID} />}
{tab === "delegations" && <DelegationsPanel client={client} runID={runID} />}
{tab === "fanout" && <FanoutPanel client={client} runID={runID} />}
Expand All @@ -487,7 +488,8 @@ export function RunWorkspace({ client, runID, onOpenPlugins }: {
</CollectionState>
)}
{tab === "context" && <ContextContinuityPanel client={client} runID={runID}
sessionID={detail.run.session_id ?? ""} workspaceID={detail.mission.workspace_id ?? ""} />}
sessionID={detail.run.session_id ?? ""} workspaceID={detail.mission.workspace_id ?? ""}
onOpenRun={onOpenRun} />}
{tab === "notes" && (
<CollectionState query={notesQuery} empty="暂无记忆">
<NoteList client={client} notes={notes} />
Expand Down
14 changes: 8 additions & 6 deletions web/src/components/workspace-checkpoint-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import type {
} from "../api/types";
import { formatBytes, formatDate, shortID } from "../lib/format";
import { useLocale } from "../lib/locale";
import { useConnectionStore } from "../state/connection";
import { v2QueryKeys } from "../v2/query-keys";
import { ErrorState, LoadingState, StatusBadge } from "./common";

type RestoreAction = "rewind" | "undo" | "redo";
Expand All @@ -44,12 +44,13 @@ const restoreIntentKey = (runID: string) => ["run", runID, "workspace-restore-in

const reversibleKinds = new Set(["file_tool", "command_batch", "git_mutation", "agent_merge"]);

export function WorkspaceCheckpointPanel({ client, runID, runStatus, variant = "inspector", onChanged }: {
export function WorkspaceCheckpointPanel({ client, runID, runStatus, variant = "inspector", onChanged, onOpenRun }: {
client: APIClient;
runID: string;
runStatus: string;
variant?: "inspector" | "conversation";
onChanged?: () => void;
onOpenRun?: (runID: string) => void;
}) {
const { t } = useLocale();
const allowFork = variant === "inspector";
Expand All @@ -61,7 +62,6 @@ export function WorkspaceCheckpointPanel({ client, runID, runStatus, variant = "
? " · 修改前" : checkpoint.phase === "after" ? " · 修改后" : ""}`;
};
const queryClient = useQueryClient();
const selectRun = useConnectionStore((state) => state.selectRun);
const timelineKey = ["run", runID, "workspace-checkpoints"] as const;
const [selectedID, setSelectedID] = useState("");
const [title, setTitle] = useState("");
Expand Down Expand Up @@ -215,10 +215,12 @@ export function WorkspaceCheckpointPanel({ client, runID, runStatus, variant = "
confirm: true,
}, operationKey);
},
onSuccess: (result) => {
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["runs"] });
void queryClient.invalidateQueries({ queryKey: ["sessions"] });
selectRun(result.run.id);
void queryClient.invalidateQueries({ queryKey: v2QueryKeys.inspectorRecords });
void queryClient.invalidateQueries({ queryKey: v2QueryKeys.threads("active") });
void queryClient.invalidateQueries({ queryKey: v2QueryKeys.workspaces });
},
});

Expand Down Expand Up @@ -261,7 +263,7 @@ export function WorkspaceCheckpointPanel({ client, runID, runStatus, variant = "
if (globalThis.confirm(t(
"确认从该检查点创建独立 Run、Git 分支和 worktree?旧权限与进程不会继承。",
"Create an independent Run, Git branch, and worktree from this checkpoint? Old authority and processes are not inherited.",
))) fork.mutate();
))) fork.mutate(undefined, { onSuccess: (result) => onOpenRun?.(result.run.id) });
};

if (timeline.isLoading && !pendingRestore) return <LoadingState label={t("加载 Workspace 检查点", "Loading Workspace checkpoints")} />;
Expand Down
11 changes: 10 additions & 1 deletion web/src/v2/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,14 @@ function V2WorkbenchContent({ client }: { client: APIClient }) {
kind: selectedThreadID ? "thread" : "new", ...(selectedThreadID ? { threadID: selectedThreadID } : {}),
view: "inspector", tool, ...(resourceID ? { resourceID } : {}),
});
const openCreatedRun = (runID: string) => {
// The mutation retains the callback from its source route. A later source
// selection must not be replaced by the completion of that older request.
if (routeRef.current !== route) return;
// Fork/Resume returns a Run, not a Thread association. Open that exact
// record without carrying the old Thread's permissions into its scope.
navigate({ kind: "new", view: "inspector", tool: "run", resourceID: runID });
};

return <div className={`v2-shell${sidebarVisible ? " has-sidebar" : " no-sidebar"}`}>
<V2Titlebar canGoBack={navigation.canGoBack} onBack={surface === "settings" ? returnFromSettings : goBack}
Expand Down Expand Up @@ -442,7 +450,8 @@ function V2WorkbenchContent({ client }: { client: APIClient }) {
? <V2LazySurface resetKey="inspector-tools"
loadingText="正在加载检查工具…" errorLabel="检查工具">
<V2InspectorTools client={client} tool={route.tool} resourceID={route.resourceID}
threadID={selectedThreadID} onBack={openInspector} onOpenSettings={setSettingsSection} /></V2LazySurface>
threadID={selectedThreadID} onBack={openInspector} onOpenSettings={setSettingsSection}
onOpenRun={openCreatedRun} /></V2LazySurface>
: view === "inspector" && !selectedThreadID
? <V2LazySurface resetKey="inspector-home" loadingText="正在加载 Inspector…" errorLabel="Inspector">
<V2InspectorHome client={client} onOpenTool={openTool} onOpenSettings={setSettingsSection} /></V2LazySurface>
Expand Down
3 changes: 2 additions & 1 deletion web/src/v2/components/inspector-record-browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { APIClient } from "../../api/client";
import type { RunView, SessionView } from "../../api/types";
import { LifecycleStatusLabel } from "../../components/lifecycle-status";
import { formatDate } from "../../lib/format";
import { v2QueryKeys } from "../query-keys";
import "./inspector-record-browser.css";

type RecordKind = "run" | "session";
Expand Down Expand Up @@ -32,7 +33,7 @@ function RecordList({ client, kind, onOpen }: {
}) {
const [search, setSearch] = useState("");
const query = useInfiniteQuery({
queryKey: ["v2", "inspector", "records", kind],
queryKey: [...v2QueryKeys.inspectorRecords, kind],
queryFn: ({ signal, pageParam }) => client.getPage<RunView | SessionView>(
kind === "run" ? "/runs" : "/sessions", { limit: 50 }, pageParam, signal),
initialPageParam: "", getNextPageParam: (page) => page.page.next_cursor || undefined,
Expand Down
5 changes: 3 additions & 2 deletions web/src/v2/components/inspector-tools.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,14 @@ import "./inspector.css";

// Advanced resource pages retain their exact scope and existing controls. They
// use the same application navigation/settings, and never infer a stale Thread.
export function V2InspectorTools({ client, tool, resourceID = "", threadID, onBack, onOpenSettings }: {
export function V2InspectorTools({ client, tool, resourceID = "", threadID, onBack, onOpenSettings, onOpenRun }: {
client: APIClient;
tool: "run" | "session" | "schedule";
resourceID?: string;
threadID: string;
onBack: () => void;
onOpenSettings: (section: V2SettingsSection) => void;
onOpenRun?: (runID: string) => void;
}) {
const title = tool === "schedule" ? "定时观察" : tool === "run" ? "运行诊断" : "会话上下文";
return <section className="v2-inspector-resource">
Expand All @@ -40,7 +41,7 @@ export function V2InspectorTools({ client, tool, resourceID = "", threadID, onBa
resourceKind={tool} runID={tool === "run" ? resourceID : ""}
sessionID={tool === "session" ? resourceID : ""}>
{tool === "run" ? <RunWorkspace client={client} key={`run:${resourceID}`} runID={resourceID}
onOpenPlugins={() => onOpenSettings("extensions")} />
onOpenPlugins={() => onOpenSettings("extensions")} onOpenRun={onOpenRun} />
: <SessionWorkspace client={client} key={`session:${resourceID}`} sessionID={resourceID}
onOpenPlugins={() => onOpenSettings("extensions")} />}
</WorkbenchFrame>}
Expand Down
1 change: 1 addition & 0 deletions web/src/v2/query-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ export const v2QueryKeys = {
searchReadiness: (threadID: string) => ["v2", "thread", threadID, "search-readiness"] as const,
approvals: (runID: string) => ["v2", "run", runID, "approvals"] as const,
workspaces: ["v2", "workspaces"] as const,
inspectorRecords: ["v2", "inspector", "records"] as const,
};
Loading
Loading