feat(agents): task actions in the context menu, Changes toggle at the header's end (#456)

Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-10-30 20:48:16 +08:00
1 джерело f265e636e9
коміт 9c31a27e30
4 files changed
+137 -104

No files matched your search

+27 -35
Переглянути файл
@@ -560,41 +560,6 @@
min-width: 0;
width: 280px;
}
.changesCollapsed {
width: 28px;
align-items: center;
padding-top: 4px;
}
.changesCollapsed > * {
flex: none;
}
.changesRail {
flex: 1;
min-height: 0;
align-self: stretch;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 4px 0 8px;
border: none;
background: transparent;
color: var(--fg-subtle);
font: inherit;
cursor: pointer;
}
.changesRail:hover {
color: var(--fg-strong);
}
.changesRailLabel {
writing-mode: vertical-rl;
font-size: var(--fs-small);
letter-spacing: 0.02em;
}
.changesRailCount {
font-size: var(--fs-small);
color: var(--fg-muted);
}
.changesHeader {
display: flex;
align-items: center;
@@ -844,3 +809,30 @@
overflow: hidden;
text-overflow: ellipsis;
}
.changesToggle {
display: inline-flex;
align-items: center;
gap: 4px;
height: 22px;
padding: 0 6px;
border: 0;
border-radius: var(--radius-small);
background: transparent;
color: var(--fg-subtle);
font: inherit;
font-size: var(--fs-small);
cursor: pointer;
}
.changesToggle:hover:not(:disabled) {
background: var(--bg-hover);
color: var(--fg-strong);
}
.changesToggle[aria-pressed="true"] {
background: var(--bg-selected);
color: var(--fg-strong);
}
.changesToggle:disabled {
opacity: 0.4;
cursor: default;
}
+84 -67
Переглянути файл
@@ -46,7 +46,6 @@ import {
type SubagentEntry,
} from "./model";
import {
archiveTask,
cancelTask,
loadEntries,
markVisible,
@@ -65,6 +64,7 @@ import {
useAgents,
} from "./store";
import { setFocusedSession, focusedSession, type FocusedSession } from "./sessionFocus";
import { taskMenu } from "./taskActions";
import styles from "./Session.module.css";
export function SessionEditor({ input }: { input: EditorInput }) {
@@ -73,6 +73,7 @@ export function SessionEditor({ input }: { input: EditorInput }) {
const entries = useAgents((s) => s.entries.get(id));
const changes = useAgents((s) => s.changes.get(id));
const head = useLocalHead(task?.isolation === "local" ? task.status : null);
const [changesOpen, setChangesOpen] = useChangesOpen(id, changes ?? null);
// The subagent conversation shown, with the subagents that lead to it (empty: the task's own).
const [path, setPath] = useState<string[]>([]);
const view = path.at(-1) ?? null;
@@ -116,7 +117,14 @@ export function SessionEditor({ input }: { input: EditorInput }) {
}}
>
<div className={styles.conversation}>
<Header task={task} entries={entries ?? null} head={head} />
<Header
task={task}
entries={entries ?? null}
head={head}
changes={changes ?? null}
changesOpen={changesOpen}
onToggleChanges={() => setChangesOpen(!changesOpen)}
/>
{view && <SubagentBar task={task} path={path} cards={cards} onNavigate={setPath} />}
<PermissionBanner task={task} entries={entries ?? null} />
<Stream
@@ -137,7 +145,9 @@ export function SessionEditor({ input }: { input: EditorInput }) {
<SessionComposer task={task} entries={entries ?? null} onOpenSubagent={openSubagent} />
)}
</div>
<ChangesPane task={task} changes={changes ?? null} head={head} />
{changesOpen && (
<ChangesPane task={task} changes={changes ?? null} head={head} onHide={() => setChangesOpen(false)} />
)}
</div>
);
}
@@ -171,11 +181,32 @@ function useLocalHead(status: AgentTask["status"] | null): string | null {
return local ? head : null;
}
function Header({ task, entries, head }: { task: AgentTask; entries: AgentEntry[] | null; head: string | null }) {
function Header({
task,
entries,
head,
changes,
changesOpen,
onToggleChanges,
}: {
task: AgentTask;
entries: AgentEntry[] | null;
head: string | null;
changes: AgentChange[] | null;
changesOpen: boolean;
onToggleChanges: () => void;
}) {
const count = changes?.length ?? 0;
const busy = isBusy(task.status);
const where = task.isolation === "worktree" ? branchLabel(task.branch) : null;
return (
<div className={styles.header}>
<div
className={styles.header}
onContextMenu={(e) => {
e.preventDefault();
showContextMenu(e, taskMenu([task]));
}}
>
<TaskStatusIcon status={task.status} />
<span className={styles.title} title={task.title}>
{task.title}
@@ -205,18 +236,6 @@ function Header({ task, entries, head }: { task: AgentTask; entries: AgentEntry[
size="small"
onClick={() => void executeAction("terminal.openAt", task.cwd)}
/>
{!busy && task.status !== "archived" && task.capabilities?.fork && (
<IconButton
icon="branch"
title="Copy to a New Task"
size="small"
onClick={() =>
void forkTask(task.id, null).catch((e: unknown) =>
notifications().notify({ level: "error", title: "Couldn't copy the task", body: String(e) }),
)
}
/>
)}
{task.status === "archived" && (
<IconButton
icon="undo"
@@ -226,9 +245,17 @@ function Header({ task, entries, head }: { task: AgentTask; entries: AgentEntry[
actionArg={[task.id]}
/>
)}
{!busy && task.status !== "archived" && (
<IconButton icon="close" title="Archive Task" size="small" onClick={() => void archiveTask(task.id)} />
)}
<button
type="button"
className={styles.changesToggle}
aria-pressed={changesOpen}
disabled={count === 0 && !changesOpen}
title={changesOpen ? tx("Hide changes") : tx("Show changes")}
onClick={onToggleChanges}
>
<Icon name="toolwindow-changes" size={14} />
{count > 0 && <span>{count}</span>}
</button>
</div>
);
}
@@ -1216,58 +1243,53 @@ const KIND_COLOR: Partial<Record<AgentChange["kind"], string>> = {
conflicted: "var(--vcs-conflict)",
};
const CHANGES_COLLAPSED_KEY = "openidea.agents.changesCollapsed.v1";
const CHANGES_OPEN_KEY = "openidea.agents.changesOpen.v1";
function loadCollapsed(): boolean {
function loadChangesOpen(): Record<string, boolean> {
try {
const raw = globalThis.localStorage?.getItem(CHANGES_COLLAPSED_KEY);
// Collapsed on first run: the chat is the focus, the diff opens on demand.
return raw === null ? true : (JSON.parse(raw) as boolean);
const raw = globalThis.localStorage?.getItem(CHANGES_OPEN_KEY);
const v: unknown = raw ? JSON.parse(raw) : {};
return v && typeof v === "object" ? (v as Record<string, boolean>) : {};
} catch {
return true;
return {};
}
}
function saveCollapsed(value: boolean) {
try {
globalThis.localStorage?.setItem(CHANGES_COLLAPSED_KEY, JSON.stringify(value));
} catch {
/* storage unavailable */
}
/**
* Whether the Changes pane is shown for `task`: the user's choice once made, else
* only while the task has changes (so it opens when the agent first edits a file).
*/
function useChangesOpen(task: string, changes: AgentChange[] | null): [boolean, (open: boolean) => void] {
const [state, setState] = useState<{ task: string; open: boolean | undefined }>(() => ({
task,
open: loadChangesOpen()[task],
}));
const chosen = state.task === task ? state.open : loadChangesOpen()[task];
const set = (open: boolean) => {
setState({ task, open });
try {
globalThis.localStorage?.setItem(CHANGES_OPEN_KEY, JSON.stringify({ ...loadChangesOpen(), [task]: open }));
} catch {
/* storage unavailable */
}
};
return [chosen ?? (changes?.length ?? 0) > 0, set];
}
function ChangesPane({ task, changes, head }: { task: AgentTask; changes: AgentChange[] | null; head: string | null }) {
function ChangesPane({
task,
changes,
head,
onHide,
}: {
task: AgentTask;
changes: AgentChange[] | null;
head: string | null;
onHide: () => void;
}) {
const [selected, setSelected] = useState<Set<string>>(() => new Set());
const [collapsed, setCollapsed] = useState<boolean>(loadCollapsed);
const added = changes?.reduce((n, c) => n + c.added, 0) ?? 0;
const removed = changes?.reduce((n, c) => n + c.removed, 0) ?? 0;
const toggleCollapsed = (next: boolean) => {
setCollapsed(next);
saveCollapsed(next);
};
if (collapsed) {
return (
<aside className={clsx(styles.changes, styles.changesCollapsed)} aria-label={tx("Changes")}>
<IconButton
icon="chevron-left"
title={tx("Show changes")}
size="small"
onClick={() => toggleCollapsed(false)}
/>
<button
type="button"
className={styles.changesRail}
title={tx("Show changes")}
onClick={() => toggleCollapsed(false)}
>
<span className={styles.changesRailLabel}>
<T>Changes</T>
</span>
{changes && changes.length > 0 && <span className={styles.changesRailCount}>{changes.length}</span>}
</button>
</aside>
);
}
return (
<aside className={styles.changes} aria-label={tx("Changes")}>
<div className={styles.changesHeader}>
@@ -1281,12 +1303,7 @@ function ChangesPane({ task, changes, head }: { task: AgentTask; changes: AgentC
)}
<span className={styles.bannerSpacer} />
<IconButton icon="refresh" title="Refresh" size="small" onClick={() => void refreshChanges(task.id)} />
<IconButton
icon="chevron-right"
title={tx("Hide changes")}
size="small"
onClick={() => toggleCollapsed(true)}
/>
<IconButton icon="chevron-right" title={tx("Hide changes")} size="small" onClick={onHide} />
</div>
{(head ?? task.base) && (
<div className={styles.changesSummary}>
+25 -1
Переглянути файл
@@ -4,9 +4,10 @@ import { tx } from "@/platform/i18n";
import type { MenuItem } from "@/ui";
import { showError, confirmChecked } from "@/workbench/messages";
import { isBusy } from "./model";
import { archiveTask, unarchiveTask, deleteTask, openTask, useAgents } from "./store";
import { archiveTask, unarchiveTask, deleteTask, forkTask, openTask, useAgents } from "./store";
const canArchive = (task: AgentTask) => !isBusy(task.status) && task.status !== "archived";
const canFork = (task: AgentTask) => canArchive(task) && !!task.capabilities?.fork;
function resolveTasks(arg: unknown): AgentTask[] {
if (!Array.isArray(arg)) return [];
@@ -67,6 +68,17 @@ export const taskActions: Action[] = [
if (typeof arg === "string") openTask(arg);
},
},
{
id: "agents.forkTask",
title: "Copy Agent Task to a New Task",
category: "Agents",
hidden: true,
run: ({ arg }) => {
const task = resolveTasks([arg])[0];
if (task && canFork(task))
return forkTask(task.id, null).catch((e: unknown) => showError(tx("Couldn't copy the task"), String(e)));
},
},
{
id: "agents.archiveTasks",
title: "Archive Agent Tasks",
@@ -108,6 +120,18 @@ export function taskMenu(tasks: AgentTask[]): MenuItem[] {
? ([
{ kind: "action", action: "agents.openTask", label: "Open", arg: task.id },
{ kind: "action", action: "terminal.openAt", label: "Open in Terminal", icon: "terminal", arg: task.cwd },
...(task.capabilities?.fork && task.status !== "archived"
? ([
{
kind: "action",
action: "agents.forkTask",
label: "Copy to a New Task",
icon: "branch",
arg: task.id,
disabled: !canFork(task),
},
] satisfies MenuItem[])
: []),
{ kind: "separator" },
] satisfies MenuItem[])
: []),
+1 -1
Переглянути файл
@@ -3314,7 +3314,7 @@
"Ready to Review": "待审阅",
"Archived": "已归档",
"Archive": "归档",
"Archive Task": "归档任务",
"Copy Agent Task to a New Task": "将 Agent 任务复制为新任务",
"Delete Agent Task": "删除 Agent 任务",
"Also remove its worktree folder": "同时删除它的 worktree 目录",
"needs input": "等待输入",