Compare commits

...

2 Commits

Author SHA1 Message Date
Jiayuan Zhang
d20f57da4b fix(issues): preserve user activation in Copy local workdir path
Move the task list subscription out of useIssueActions and into
IssueActionsMenuItems, where Base UI lazily mounts the menu content
only after the user opens the menu. The click handler now reads
straight from the cached query result and writes to the clipboard
synchronously, so the awaited fetch no longer drops the browser's
transient user activation when the cache is cold (e.g. opening the
context menu on an issue list row that hasn't pre-populated the
ExecutionLogSection cache).

Per Emacs PR review.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-07 11:53:29 +08:00
Jiayuan Zhang
94604e6180 feat(issues): add Copy local workdir path to issue menu
Surface the daemon-pinned task work_dir on the AgentTaskResponse and add a
"Copy local workdir path" action to the issue dropdown / context menu. The
action picks the most recent task with a recorded work_dir and writes it
to the clipboard so users can jump straight to the local execution
directory to inspect results.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-07 11:11:52 +08:00
5 changed files with 75 additions and 3 deletions

View File

@@ -95,6 +95,11 @@ export interface AgentTask {
* with a meaningful title instead of falling through to "Untracked").
*/
kind?: "comment" | "autopilot" | "chat" | "quick_create" | "direct";
/**
* Local working directory pinned for this task by the daemon. Empty until
* the daemon reports a work_dir (typically once execution starts).
*/
work_dir?: string;
}
export interface Agent {

View File

@@ -1,9 +1,13 @@
"use client";
import { useCallback } from "react";
import { useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import {
ArrowDown,
ArrowUp,
Calendar,
FolderOpen,
Link2,
MoreHorizontal,
Pin,
@@ -12,12 +16,14 @@ import {
Trash2,
UserMinus,
} from "lucide-react";
import type { Issue } from "@multica/core/types";
import type { AgentTask, Issue } from "@multica/core/types";
import { api } from "@multica/core/api";
import {
ALL_STATUSES,
PRIORITY_ORDER,
PRIORITY_CONFIG,
} from "@multica/core/issues/config";
import { issueKeys } from "@multica/core/issues/queries";
import { StatusIcon } from "../components/status-icon";
import { PriorityIcon } from "../components/priority-icon";
import { ActorAvatar } from "../../common/actor-avatar";
@@ -103,6 +109,37 @@ export function IssueActionsMenuItems({
return d.toISOString();
};
// Subscribe to the issue's task list so the cache is warm by the time the
// user clicks "Copy local workdir path". The query only fires while the
// menu is open (Base UI portals the menu content lazily) — list views
// that wrap every row in IssueActionsContextMenu pay nothing until the
// menu actually opens.
//
// The query shares its key with ExecutionLogSection, so navigating from
// the issue detail page is a free cache hit.
const { data: tasks } = useQuery({
queryKey: issueKeys.tasks(issue.id),
queryFn: () => api.listTasksByIssue(issue.id),
staleTime: 30_000,
});
// Synchronous click handler — the awaited fetch in the previous version
// dropped the browser's transient user activation, which made
// navigator.clipboard.writeText() reject from the menu when the cache
// was cold. We now read straight from the cached query result and write
// to the clipboard inside the same task as the click.
const handleCopyWorkdirPath = useCallback(() => {
const latestWorkDir = pickLatestWorkDir(tasks);
if (!latestWorkDir) {
toast.error(t(($) => $.detail.workdir_path_unavailable));
return;
}
navigator.clipboard.writeText(latestWorkDir).then(
() => toast.success(t(($) => $.detail.workdir_path_copied)),
() => toast.error(t(($) => $.detail.workdir_path_copy_failed)),
);
}, [tasks, t]);
return (
<>
{/* Status */}
@@ -238,6 +275,10 @@ export function IssueActionsMenuItems({
<Link2 className="h-3.5 w-3.5" />
{t(($) => $.actions.copy_link)}
</P.Item>
<P.Item onClick={handleCopyWorkdirPath}>
<FolderOpen className="h-3.5 w-3.5" />
{t(($) => $.actions.copy_workdir_path)}
</P.Item>
<P.Separator />
@@ -276,3 +317,15 @@ export function IssueActionsMenuItems({
</>
);
}
function pickLatestWorkDir(tasks: AgentTask[] | undefined): string | undefined {
if (!tasks?.length) return undefined;
let latest: AgentTask | undefined;
for (const task of tasks) {
if (!task.work_dir) continue;
if (!latest || task.created_at > latest.created_at) {
latest = task;
}
}
return latest?.work_dir;
}

View File

@@ -128,7 +128,10 @@
"sidebar_tooltip": "Toggle sidebar",
"update_failed": "Failed to update issue",
"link_copied": "Link copied",
"link_copy_failed": "Failed to copy link"
"link_copy_failed": "Failed to copy link",
"workdir_path_copied": "Workdir path copied",
"workdir_path_copy_failed": "Failed to copy workdir path",
"workdir_path_unavailable": "No local workdir yet — issue has not been run by a local agent"
},
"timeline": {
"show_older": "Show older",
@@ -248,6 +251,7 @@
"pin_to_sidebar": "Pin to sidebar",
"unpin_from_sidebar": "Unpin from sidebar",
"copy_link": "Copy link",
"copy_workdir_path": "Copy local workdir path",
"more": "More",
"create_sub_issue": "Create sub-issue",
"set_parent_issue": "Set parent issue...",

View File

@@ -127,7 +127,10 @@
"sidebar_tooltip": "切换侧边栏",
"update_failed": "更新 issue 失败",
"link_copied": "已复制链接",
"link_copy_failed": "复制链接失败"
"link_copy_failed": "复制链接失败",
"workdir_path_copied": "已复制本地 workdir 路径",
"workdir_path_copy_failed": "复制本地 workdir 路径失败",
"workdir_path_unavailable": "暂无本地 workdir — 这个 issue 还没被本地 agent 运行过"
},
"timeline": {
"show_older": "显示更早",
@@ -241,6 +244,7 @@
"pin_to_sidebar": "固定到侧边栏",
"unpin_from_sidebar": "从侧边栏取消固定",
"copy_link": "复制链接",
"copy_workdir_path": "复制本地 workdir 路径",
"more": "更多",
"create_sub_issue": "创建子 issue",
"set_parent_issue": "设置父 issue...",

View File

@@ -158,6 +158,7 @@ type AgentTaskResponse struct {
CreatedAt string `json:"created_at"`
PriorSessionID string `json:"prior_session_id,omitempty"` // session ID from a previous task on same issue
PriorWorkDir string `json:"prior_work_dir,omitempty"` // work_dir from a previous task on same issue
WorkDir string `json:"work_dir,omitempty"` // local working directory pinned for this task; populated once the daemon reports it
TriggerCommentID *string `json:"trigger_comment_id,omitempty"` // comment that triggered this task
TriggerCommentContent string `json:"trigger_comment_content,omitempty"` // content of the triggering comment
TriggerSummary *string `json:"trigger_summary,omitempty"` // canonical short description snapshot — comment text / autopilot title — taken at task creation; survives source edits/deletes
@@ -197,6 +198,10 @@ func taskToResponse(t db.AgentTaskQueue) AgentTaskResponse {
if t.FailureReason.Valid {
failureReason = t.FailureReason.String
}
workDir := ""
if t.WorkDir.Valid {
workDir = t.WorkDir.String
}
return AgentTaskResponse{
ID: uuidToString(t.ID),
AgentID: uuidToString(t.AgentID),
@@ -216,6 +221,7 @@ func taskToResponse(t db.AgentTaskQueue) AgentTaskResponse {
CreatedAt: timestampToString(t.CreatedAt),
TriggerCommentID: uuidToPtr(t.TriggerCommentID),
TriggerSummary: textToPtr(t.TriggerSummary),
WorkDir: workDir,
// Surface task source so the UI can distinguish issue-linked tasks
// from chat-spawned or autopilot-spawned ones; all three may arrive
// with issue_id = "" once a task has no linked issue.