diff --git a/desktop/frontend-next/src/actions.ts b/desktop/frontend-next/src/actions.ts index 16d07b6d42..981395204a 100644 --- a/desktop/frontend-next/src/actions.ts +++ b/desktop/frontend-next/src/actions.ts @@ -113,6 +113,15 @@ export const ACTIONS: UIAction[] = [ { id: "prompt.adopt", kind: "view", target: "none", proof: "interaction" }, { id: "prompt.discard", kind: "view", target: "none", proof: "interaction" }, + // ── Committing what is staged ──────────────────────────────────────────── + // Drafting reads the index and asks the model; only confirm records a commit, + // and only locally. + { id: "commit.draft", kind: "repeatable", target: "none", proof: "interaction" }, + { id: "commit.edit", kind: "view", target: "none", proof: "interaction" }, + { id: "commit.acknowledge", kind: "view", target: "none", proof: "interaction" }, + { id: "commit.confirm", kind: "kernel-mutation", target: "none", proof: "authority-effect" }, + { id: "commit.close", kind: "view", target: "none", proof: "interaction" }, + // ── What is waiting to be sent ─────────────────────────────────────────── { id: "queue.edit", kind: "kernel-mutation", target: "entity", proof: "interaction" }, // Giving a session a name of your own. The sidebar's tree and the pane tabs diff --git a/desktop/frontend-next/src/i18n/en.ts b/desktop/frontend-next/src/i18n/en.ts index d37e96c5dd..929c4fdc15 100644 --- a/desktop/frontend-next/src/i18n/en.ts +++ b/desktop/frontend-next/src/i18n/en.ts @@ -1213,6 +1213,64 @@ export const EN: Record = { "The model returned nothing; try again", "优化请求格式不正确": "The refine request was malformed", + "提交…": + "Commit…", + "起草提交说明": + "Draft commit message", + "读取暂存区,起草提交说明…": + "Reading the staged changes and drafting a message…", + "重新起草": + "Redraft", + "提交说明": + "Commit message", + "暂存的文件": + "Staged files", + "{n} 个文件已暂存": + "{n} files staged", + "可能含有密钥:{files}": + "May contain secrets: {files}", + "新增的内容里有形似密钥的值": + "Added lines contain values shaped like credentials", + "我确认这些内容可以提交": + "I confirm these can be committed", + "提交到本地": + "Commit locally", + "只在本地提交,不会推送": + "Recorded locally only; nothing is pushed", + "已提交 {hash}:{subject}": + "Committed {hash}: {subject}", + "改过暂存区后请重新起草": + "Draft again after changing what is staged", + "暂存区内容过长,只按前面一部分起草": + "The staged diff is long; the draft reads only its first part", + "这个工作区不是 git 仓库,无法提交": + "This workspace is not a git repository, so there is nothing to commit", + "暂存区是空的,先用 git add 暂存要提交的文件": + "Nothing is staged; stage the files to commit with git add first", + "暂存区在你确认之后又变了,请重新起草": + "The staged changes changed after you reviewed them; draft again", + "暂存的内容里有疑似密钥的文件,需要你确认后才能提交": + "The staged changes include likely secrets; confirm them before committing", + "提交说明是空的": + "The commit message is empty", + "提交说明含有无法记录的字符": + "The commit message contains characters git cannot record", + "提交说明超过 {max_bytes} 字节": + "The commit message is over {max_bytes} bytes", + "git 没有配置提交者姓名和邮箱(user.name、user.email)": + "git has no author name and email configured (user.name, user.email)", + "git 没能记录这次提交": + "git could not record the commit", + "当前会话没有可用的模型,无法起草提交说明": + "This session has no model to draft a commit message with", + "起草提交说明超时,请重试": + "Drafting the commit message timed out; try again", + "模型没有给出提交说明,请重试": + "The model returned no commit message; try again", + "读取暂存区失败": + "Reading the staged changes failed", + "提交请求格式不正确": + "The commit request was malformed", "新增工具": "New tools", "agent 可以调用,每次调用照常经过权限确认": diff --git a/desktop/frontend-next/src/i18n/kernel.ts b/desktop/frontend-next/src/i18n/kernel.ts index be4b344744..70f97ce239 100644 --- a/desktop/frontend-next/src/i18n/kernel.ts +++ b/desktop/frontend-next/src/i18n/kernel.ts @@ -215,6 +215,21 @@ const SAID: Record = { "prompt_refine.failed": "优化失败,请重试", "prompt_refine.bad_request": "优化请求格式不正确", + "commit.no_repository": "这个工作区不是 git 仓库,无法提交", + "commit.nothing_staged": "暂存区是空的,先用 git add 暂存要提交的文件", + "commit.staged_changed": "暂存区在你确认之后又变了,请重新起草", + "commit.secrets_staged": "暂存的内容里有疑似密钥的文件,需要你确认后才能提交", + "commit.empty_message": "提交说明是空的", + "commit.message_invalid": "提交说明含有无法记录的字符", + "commit.message_too_long": "提交说明超过 {max_bytes} 字节", + "commit.identity_missing": "git 没有配置提交者姓名和邮箱(user.name、user.email)", + "commit.failed": "git 没能记录这次提交", + "commit.no_model": "当前会话没有可用的模型,无法起草提交说明", + "commit.timeout": "起草提交说明超时,请重试", + "commit.no_answer": "模型没有给出提交说明,请重试", + "commit.git_failed": "读取暂存区失败", + "commit.bad_request": "提交请求格式不正确", + // ── 会话 ───────────────────────────────────────────────────────── "session.disabled": "这台服务器已关闭会话切换", "session.pending_cleanup": "该会话正在清理,请稍后再打开", diff --git a/desktop/frontend-next/src/port/mock_commit.ts b/desktop/frontend-next/src/port/mock_commit.ts new file mode 100644 index 0000000000..b4d4f13ced --- /dev/null +++ b/desktop/frontend-next/src/port/mock_commit.ts @@ -0,0 +1,22 @@ +import { MockBackup } from "./mock_backup"; +import type { CommitProposal, CommitRequest, CommitResult } from "./workspace"; + +export class MockCommit extends MockBackup { + async proposeCommit(): Promise { + await new Promise((r) => setTimeout(r, 600)); + return { + message: "fix(provider): back off retries on 429 and drop credentials from logs\n\nHonour Retry-After before the next attempt and stop printing the key.", + fingerprint: "mock", + files: [ + { path: "internal/provider/retry.go", status: "M", insertions: 14, deletions: 3 }, + { path: "internal/config/credentials.go", status: "M", insertions: 6, deletions: 2 }, + ], + truncated: false, + contentSecrets: false, + }; + } + + async commitStaged(req: CommitRequest): Promise { + return { hash: "0123456789abcdef0123456789abcdef01234567", subject: req.message.split("\n")[0] }; + } +} diff --git a/desktop/frontend-next/src/port/mock_feedback.ts b/desktop/frontend-next/src/port/mock_feedback.ts index fc2200e32f..bef14228ad 100644 --- a/desktop/frontend-next/src/port/mock_feedback.ts +++ b/desktop/frontend-next/src/port/mock_feedback.ts @@ -1,5 +1,5 @@ import { HttpError } from "./http_error"; -import { MockBackup } from "./mock_backup"; +import { MockCommit } from "./mock_commit"; import { FEEDBACK_CODE, type FeedbackEnv, type FeedbackItem, type FeedbackMine, type FeedbackReceipt, type FeedbackReply, type FeedbackReplyReceipt, type FeedbackRequest } from "./feedback"; const LIMITS = { bodyBytes: 8192, nameChars: 40, contactChars: 120, images: 3, imageBytes: 2 << 20, uploadBytes: 10 << 20, replyBytes: 4096 }; @@ -59,7 +59,7 @@ function refusal(code: string): HttpError { return new HttpError(STATUS[code] ?? 500, code, { code, error: code, params: code === FEEDBACK_CODE.rateLimited ? { retryAfterSeconds: 90 } : {} }); } -export class MockFeedback extends MockBackup { +export class MockFeedback extends MockCommit { private filed: FeedbackItem[] = []; private name = ""; private rows: FeedbackItem[] = SEEDED.map((i) => ({ ...i, replies: [...i.replies] })); diff --git a/desktop/frontend-next/src/port/port.ts b/desktop/frontend-next/src/port/port.ts index f0f189fbba..9a2028e5e9 100644 --- a/desktop/frontend-next/src/port/port.ts +++ b/desktop/frontend-next/src/port/port.ts @@ -21,7 +21,7 @@ import type { ApprovalDefault, ApprovalMode, WorkspaceTrust, ApprovalVerdict, Br import type { ContextBreakdown, ShellOption, ShellSettings } from "./shell"; import type { SkillCatalog, SkillEntry } from "./skill"; import type { UpdateProgress, VersionEntry, VersionHub } from "./version"; -import type { ChangeDiff, WorkspaceChange, WorkspaceChanges, WorkspaceEntry, WorkspaceFile, WorkspaceFiles, WorkspaceInfo } from "./workspace"; +import type { ChangeDiff, CommitFile, CommitProposal, CommitRequest, CommitResult, WorkspaceChange, WorkspaceChanges, WorkspaceEntry, WorkspaceFile, WorkspaceFiles, WorkspaceInfo } from "./workspace"; // The port is one contract; its subjects each keep their own file, the way the // wire and the layers below already do. This is where a reader still finds @@ -33,7 +33,7 @@ export type { AccountState, AccountUser, ApprovalDefault, ApprovalMode, Approval MemoryEntry, ModelEntry, ModelMode, ModelPrice, NetworkProbe, NetworkSettings, Preset, RewindPlan, RewindResult, RewindScope, RoleAssignments, ScopeLayer, SessionEntry, SessionStatus, ShellOption, ShellSettings, SkillCatalog, SkillEntry, UpdateProgress, VersionEntry, - VersionHub, WalletLine, WalletReading, ChangeDiff, WorkspaceChange, WorkspaceChanges, WorkspaceEntry, WorkspaceFile, WorkspaceFiles, WorkspaceInfo }; + VersionHub, WalletLine, WalletReading, ChangeDiff, CommitFile, CommitProposal, CommitRequest, CommitResult, WorkspaceChange, WorkspaceChanges, WorkspaceEntry, WorkspaceFile, WorkspaceFiles, WorkspaceInfo }; import type { ExecutionGraphRead, TrajectoryRead, WireEvent } from "./wire"; import type { PluginExport, PluginInstallRequest, PluginPackage, PluginPlan } from "./plugin"; @@ -431,6 +431,10 @@ export interface AgentPort { // only this says how, and asking per path is what keeps a session that // touched two hundred files from shipping two hundred diffs nobody opened. changeDiff(path: string): Promise; + // Drafts a message for what is staged. Nothing is committed: the person edits + // the text, and commitStaged records it locally once they confirm. + proposeCommit(signal?: AbortSignal): Promise; + commitStaged(req: CommitRequest): Promise; // Dot entries are left out unless hidden is set; VCS stores never appear. workspaceFiles(path?: string, query?: string, hidden?: boolean): Promise; workspaceFile(path: string): Promise; diff --git a/desktop/frontend-next/src/port/sse.ts b/desktop/frontend-next/src/port/sse.ts index 441cdd4d0b..c958aadc30 100644 --- a/desktop/frontend-next/src/port/sse.ts +++ b/desktop/frontend-next/src/port/sse.ts @@ -1,6 +1,6 @@ import { PLAN_ACTIONS, type PlanAction } from "./session"; import type { AccountState, AgentPort, Appearance, ChipCall, CompactionSettings, Completion, DeviceGrant, ProviderProbe, UpdateProgress, VersionHub, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, SessionEntry, SessionStatus, WalletReading, HookDryRun, HookEntry, MemoryCatalog, MemoryEdit, MemoryEntry, UsageQuery, UsageReport, McpDraft, PluginExport, Queue, Queued, NotifyPrefs, TrayPrefs, WorkspaceInfo } from "./port"; -import { HttpError, type Attachment, type ChangeDiff, type DroppedRef, type WorkspaceFile, type WorkspaceFiles, type WorkspaceChanges } from "./port"; +import { HttpError, type Attachment, type ChangeDiff, type CommitProposal, type CommitRequest, type CommitResult, type DroppedRef, type WorkspaceFile, type WorkspaceFiles, type WorkspaceChanges } from "./port"; import { SseFeedback } from "./sse_feedback"; import type { StoragePlan, StorageQuery, StorageState } from "./storage"; import type { ExecutionGraphRead, TrajectoryRead, WireEvent } from "./wire"; @@ -443,6 +443,18 @@ export class SsePort extends SseFeedback implements AgentPort { return this.get(`/changes/diff?path=${encodeURIComponent(path)}`); } + async proposeCommit(signal?: AbortSignal) { + const res = await fetch(this.base + "/commit/propose", { + method: "POST", headers: { "content-type": "application/json" }, credentials: "same-origin", body: "{}", signal, + }); + if (!res.ok) await SsePort.fail("/commit/propose", res); + return (await res.json()) as CommitProposal; + } + + async commitStaged(req: CommitRequest) { + return this.post0("/commit", req); + } + workspaceFiles(path = "", query = "", hidden = false) { const params = new URLSearchParams(); if (path) params.set("path", path); diff --git a/desktop/frontend-next/src/port/workspace.ts b/desktop/frontend-next/src/port/workspace.ts index 10f0290dd4..69c937abf9 100644 --- a/desktop/frontend-next/src/port/workspace.ts +++ b/desktop/frontend-next/src/port/workspace.ts @@ -51,3 +51,35 @@ export interface WorkspaceInfo { recents: WorkspaceEntry[]; isolated?: boolean; } + +export interface CommitFile { + path: string; + // git raw status letter: "A", "M", "D", "T". + status: string; + insertions?: number; + deletions?: number; + // The name marks a file that holds secrets; its diff is never sent to a model. + sensitive?: boolean; +} + +// The staged set as read, and the message the model proposed for it. +// fingerprint names that set: committing hands it back, and the kernel refuses +// an index that has since changed. +export interface CommitProposal { + message: string; + fingerprint: string; + files: CommitFile[]; + truncated: boolean; + contentSecrets: boolean; +} + +export interface CommitRequest { + message: string; + fingerprint: string; + acknowledgeSecrets: boolean; +} + +export interface CommitResult { + hash: string; + subject: string; +} diff --git a/desktop/frontend-next/src/styles/studio.css b/desktop/frontend-next/src/styles/studio.css index d48628672e..a89381fa04 100644 --- a/desktop/frontend-next/src/styles/studio.css +++ b/desktop/frontend-next/src/styles/studio.css @@ -1416,6 +1416,8 @@ body { background: var(--page) !important; } .workbench-changes { flex: none; max-height: 34%; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; border-bottom: 1px solid var(--hair); scrollbar-gutter: stable; } .workbench-changes .workbench-explorer-head { position: sticky; top: 0; z-index: 1; border-bottom: 0; background: var(--page); } .workbench-changes > button { flex: none; } +.workbench-changes:has(.commit-card:not([data-phase="idle"])) { max-height: 80%; } +.workbench-changes .commit-card { margin: 6px 10px 8px; } .workbench-change-row { width: 100%; min-height: 26px; padding: 0 10px 0 8px; display: flex; align-items: center; gap: 7px; border: 0; background: none; color: var(--muted); font: 450 11.5px/1.35 var(--ui); @@ -4041,7 +4043,48 @@ body { font-size: 14px; line-height: 1.75; } .refine-wait { margin: 0; color: var(--faint); font: 400 12px/1.5 var(--ui); } .refine-error { margin: 0; color: var(--err); font: 400 12px/1.5 var(--ui); } .refine-card footer { display: flex; justify-content: flex-end; gap: 6px; } -:root[data-theme="light"] .refine-card { background: var(--page); } +.commit-card { display: grid; gap: 8px; margin: 8px 0 0; } +.commit-open { margin-left: auto; } +.workbench-explorer-head .commit-open { height: 20px; padding: 0 8px; font-size: 11px; } +.commit-files li i { font-style: normal; } +.commit-files li i[data-io="up"] { color: var(--ok); } +.commit-files li i[data-io="down"] { color: var(--err); } +.commit-card:not([data-phase="idle"]) { + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--hair); +} +.commit-card header { display: flex; align-items: center; gap: 6px; color: var(--muted); font: 600 11px/1.4 var(--ui); } +.commit-card header b { color: var(--text); font-weight: 600; } +.commit-card header small { margin-left: auto; } +.commit-card small { color: var(--ghost); font: 400 10px/1.4 var(--ui); } +.commit-card footer { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 6px; } +.commit-done { margin: 0; color: var(--text); font: 400 12px/1.5 var(--ui); overflow-wrap: anywhere; } +.commit-files { display: grid; gap: 2px; max-height: 120px; margin: 0; padding: 0; overflow: auto; list-style: none; font: 400 11px/1.5 var(--mono); } +.commit-files li { display: flex; gap: 6px; min-width: 0; color: var(--muted); } +.commit-path { display: flex; flex: 1; min-width: 0; } +.commit-path .commit-dir { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; direction: rtl; } +.commit-path .commit-dir::after { content: "\200E"; } +.commit-path .commit-base { flex: none; white-space: nowrap; } +.commit-files li[data-sensitive] { color: var(--err); } +.commit-warn { display: grid; gap: 4px; padding: 6px 8px; border: 1px solid var(--err); border-radius: 8px; color: var(--err); font: 400 11px/1.5 var(--ui); } +.commit-warn p { margin: 0; overflow-wrap: anywhere; } +.commit-warn label { display: flex; align-items: center; gap: 6px; color: var(--text); } +.commit-message { + width: 100%; + box-sizing: border-box; + resize: vertical; + padding: 8px 10px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--page); + color: var(--text); + font: 400 12px/1.5 var(--mono); +} +:root[data-theme="light"] .refine-card, +:root[data-theme="light"] .commit-card:not([data-phase="idle"]) { background: var(--page); } +:root[data-theme="light"] .commit-message { background: var(--bg); } @media (hover: none) { .flow .user-acts, .wsdel, diff --git a/desktop/frontend-next/src/ui/CommitCard.tsx b/desktop/frontend-next/src/ui/CommitCard.tsx new file mode 100644 index 0000000000..afcb6a5536 --- /dev/null +++ b/desktop/frontend-next/src/ui/CommitCard.tsx @@ -0,0 +1,182 @@ +import { useEffect, useRef, useState } from "react"; +import type { AgentPort, CommitProposal, CommitResult } from "../port/port"; +import { t } from "../i18n"; +import { reason } from "../i18n/kernel"; + +type Phase = + | { phase: "idle" } + | { phase: "drafting" } + | { phase: "ready"; proposal: CommitProposal; message: string; ack: boolean; committing: boolean; error?: string } + | { phase: "done"; result: CommitResult } + | { phase: "failed"; error: string }; + +// A proposal, not an action: the model drafts for what is staged, the person +// edits, and only the confirm button records a local commit. Nothing here stages +// a file or pushes. +export function useCommitCard(port: AgentPort, onCommitted: () => void) { + const [state, setState] = useState({ phase: "idle" }); + const pending = useRef(null); + useEffect(() => () => pending.current?.abort(), []); + + const draft = () => { + pending.current?.abort(); + const ac = new AbortController(); + pending.current = ac; + setState({ phase: "drafting" }); + port.proposeCommit(ac.signal).then( + (proposal) => + !ac.signal.aborted && setState({ phase: "ready", proposal, message: proposal.message, ack: false, committing: false }), + (e) => !ac.signal.aborted && setState({ phase: "failed", error: reason(e) }), + ); + }; + const close = () => { + pending.current?.abort(); + pending.current = null; + setState({ phase: "idle" }); + }; + + const button = ( + + ); + if (state.phase === "idle") return { button, card: null }; + + if (state.phase === "drafting") { + return { + button, + card: ( +
+

{t("读取暂存区,起草提交说明…")}

+
+ +
+
+ ), + }; + } + + if (state.phase === "failed") { + return { + button, + card: ( +
+

{state.error}

+
+ + +
+
+ ), + }; + } + + if (state.phase === "done") { + return { + button, + card: ( +
+

+ {t("已提交 {hash}:{subject}", { hash: state.result.hash.slice(0, 7), subject: state.result.subject })} +

+ {t("只在本地提交,不会推送")} +
+ +
+
+ ), + }; + } + + const { proposal, message, ack, committing, error } = state; + const sensitive = proposal.files.filter((f) => f.sensitive).map((f) => f.path); + const warned = sensitive.length > 0 || proposal.contentSecrets; + const set = (patch: Partial) => setState({ ...state, ...patch }); + const confirm = () => { + set({ committing: true, error: undefined }); + port + .commitStaged({ message, fingerprint: proposal.fingerprint, acknowledgeSecrets: ack }) + .then( + (result) => { + setState({ phase: "done", result }); + onCommitted(); + }, + (e) => setState({ ...state, committing: false, error: reason(e) }), + ); + }; + + return { + button, + card: ( +
+
+ {t("暂存的文件")} + {t("{n} 个文件已暂存", { n: proposal.files.length })} +
+
    + {proposal.files.map((f) => ( +
  • + {f.status} + + {f.insertions !== undefined && +{f.insertions}} + {f.deletions !== undefined && -{f.deletions}} +
  • + ))} +
+ {proposal.truncated && {t("暂存区内容过长,只按前面一部分起草")}} + {warned && ( +
+ {sensitive.length > 0 &&

{t("可能含有密钥:{files}", { files: sensitive.join(", ") })}

} + {proposal.contentSecrets &&

{t("新增的内容里有形似密钥的值")}

} + +
+ )} +