дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
feat(agents): task actions in the context menu, Changes toggle at the header's end (#456)
Цей коміт міститься в:
1 джерело
f265e636e9
коміт
9c31a27e30
4 files changed
+137
-104
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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}>
|
||||
|
||||
@@ -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[])
|
||||
: []),
|
||||
|
||||
@@ -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": "等待输入",
|
||||
|
||||
Reference in new issue
Заблокувати користувача