From 44cf181227932e82678a94a3c5b26fc406ce5206 Mon Sep 17 00:00:00 2001 From: jack Date: Thu, 30 Jul 2026 23:16:48 +0800 Subject: [PATCH 1/2] feat(desktop): add task context titlebar --- desktop/src-tauri/capabilities/default.json | 27 + internal/web/server.go | 38 +- internal/web/tasks_test.go | 38 ++ web/src/App.tsx | 21 +- web/src/components/CloudSyncToggle.tsx | 55 +- web/src/components/DesktopTitlebar.test.tsx | 162 ++++++ web/src/components/DesktopTitlebar.tsx | 542 ++++++++++++++++++++ web/src/components/TopBar.tsx | 6 +- web/src/i18n/locales/en.ts | 16 + web/src/i18n/locales/ja.ts | 16 + web/src/i18n/locales/ko.ts | 16 + web/src/i18n/locales/zh-Hans.ts | 16 + web/src/i18n/locales/zh-Hant.ts | 16 + web/src/lib/api.ts | 5 +- web/src/lib/useDesktop.test.ts | 12 + web/src/lib/useDesktop.ts | 22 + 16 files changed, 990 insertions(+), 18 deletions(-) create mode 100644 web/src/components/DesktopTitlebar.test.tsx create mode 100644 web/src/components/DesktopTitlebar.tsx create mode 100644 web/src/lib/useDesktop.test.ts diff --git a/desktop/src-tauri/capabilities/default.json b/desktop/src-tauri/capabilities/default.json index cb434fc6..9cd61faa 100644 --- a/desktop/src-tauri/capabilities/default.json +++ b/desktop/src-tauri/capabilities/default.json @@ -19,6 +19,33 @@ "core:event:default", "notification:default", "opener:default", + { + "identifier": "opener:allow-open-path", + "allow": [ + { "path": "$HOME/**", "app": "Finder" }, + { "path": "$HOME/**", "app": "Visual Studio Code" }, + { "path": "$HOME/**", "app": "Cursor" }, + { "path": "$HOME/**", "app": "Zed" }, + { "path": "$HOME/**", "app": "Antigravity" }, + { "path": "$HOME/**", "app": "Terminal" }, + { "path": "$HOME/**", "app": "iTerm" }, + { "path": "$HOME/**", "app": "Ghostty" }, + { "path": "$HOME/**", "app": "Warp" }, + { "path": "$HOME/**", "app": "Xcode" }, + { "path": "$HOME/**", "app": "GoLand" }, + { "path": "/Volumes/**", "app": "Finder" }, + { "path": "/Volumes/**", "app": "Visual Studio Code" }, + { "path": "/Volumes/**", "app": "Cursor" }, + { "path": "/Volumes/**", "app": "Zed" }, + { "path": "/Volumes/**", "app": "Antigravity" }, + { "path": "/Volumes/**", "app": "Terminal" }, + { "path": "/Volumes/**", "app": "iTerm" }, + { "path": "/Volumes/**", "app": "Ghostty" }, + { "path": "/Volumes/**", "app": "Warp" }, + { "path": "/Volumes/**", "app": "Xcode" }, + { "path": "/Volumes/**", "app": "GoLand" } + ] + }, { "identifier": "opener:allow-open-url", "allow": [ diff --git a/internal/web/server.go b/internal/web/server.go index c831f76b..0900f4dc 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -685,22 +685,36 @@ func (s *Server) currentModelContextLimit(eng *Engine) int { } // handleWorkspace returns lightweight git workspace info (branch + dirty) for -// the current project so the web UI can show the real branch name. Diff stats +// the requested task, or the foreground project for legacy callers. Diff stats // are fetched separately via /api/diff. Empty branch = not a git repo. func (s *Server) handleWorkspace(w http.ResponseWriter, r *http.Request) { + pwd := s.activePwd() + if taskID := r.URL.Query().Get("task_id"); taskID != "" { + var err error + pwd, err = s.workspacePwdForTask(taskID) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + if pwd == "" { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "task workspace not found"}) + return + } + } + // Use the request context so the git commands are cancelled if the client // disconnects (CodeRabbit review feedback on PR #82). // `branch --show-current` (not `rev-parse --abbrev-ref HEAD`) so a freshly // initialised repo with no commits still reports its unborn branch (e.g. // "main") instead of the literal "HEAD". branchCmd := exec.CommandContext(r.Context(), "git", "branch", "--show-current") - branchCmd.Dir = s.activePwd() + branchCmd.Dir = pwd branchCmd.Env = utils.ScrubbedGitEnv() branchOut, _ := branchCmd.Output() branch := strings.TrimSpace(string(branchOut)) statusCmd := exec.CommandContext(r.Context(), "git", "status", "--porcelain") - statusCmd.Dir = s.activePwd() + statusCmd.Dir = pwd statusCmd.Env = utils.ScrubbedGitEnv() statusOut, _ := statusCmd.Output() dirty := strings.TrimSpace(string(statusOut)) != "" @@ -711,6 +725,24 @@ func (s *Server) handleWorkspace(w http.ResponseWriter, r *http.Request) { }) } +func (s *Server) workspacePwdForTask(taskID string) (string, error) { + if eng := s.resolveEngine(taskID); eng != nil { + return eng.pwd, nil + } + all, err := session.ListAllSessions() + if err != nil { + return "", fmt.Errorf("list task workspaces: %w", err) + } + for project, metas := range all { + for i := range metas { + if metas[i].UUID == taskID { + return project, nil + } + } + } + return "", nil +} + // --- Helpers --- func writeJSON(w http.ResponseWriter, status int, data any) { diff --git a/internal/web/tasks_test.go b/internal/web/tasks_test.go index 133ac448..2f0fd70f 100644 --- a/internal/web/tasks_test.go +++ b/internal/web/tasks_test.go @@ -6,6 +6,7 @@ import ( "net/http" "net/http/httptest" "os" + "os/exec" "path/filepath" "strings" "testing" @@ -55,6 +56,43 @@ func TestWorkspaceNonGit(t *testing.T) { } } +func TestWorkspaceUsesRequestedTaskProject(t *testing.T) { + activeDir := initGitWorkspace(t, "active-branch") + taskDir := initGitWorkspace(t, "task-branch") + seedIndex(t, map[string][]session.SessionMeta{ + taskDir: {{UUID: "requested-task", Project: taskDir}}, + }) + s := &Server{ + Engine: &Engine{pwd: activeDir, taskID: "active-task"}, + } + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/workspace?task_id=requested-task", nil) + s.handleWorkspace(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("code=%d body=%q", rec.Code, rec.Body.String()) + } + var ws struct { + Branch string `json:"branch"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &ws); err != nil { + t.Fatalf("not JSON: %v", err) + } + if ws.Branch != "task-branch" { + t.Fatalf("branch=%q, want requested task branch", ws.Branch) + } +} + +func initGitWorkspace(t *testing.T, branch string) string { + t.Helper() + dir := t.TempDir() + cmd := exec.Command("git", "init", "-b", branch) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git init: %v\n%s", err, out) + } + return dir +} + // P0-2: GET /api/tasks with no index returns an empty array (not null). func TestListAllTasksEmpty(t *testing.T) { seedIndex(t, map[string][]session.SessionMeta{}) diff --git a/web/src/App.tsx b/web/src/App.tsx index bebe37c5..81b0dd1c 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -54,11 +54,13 @@ import { SetupView } from './components/SetupView' import { SettingsView } from './components/SettingsView' import { TopBar } from './components/TopBar' import { CloudSyncToggle } from './components/CloudSyncToggle' +import { DesktopTitlebar } from './components/DesktopTitlebar' import { ComputerShotPiP } from './components/ComputerShotPiP' import { RightPanel } from './components/RightPanel' import { TerminalPanel } from './components/TerminalPanel' import { RemoteConnectWizard } from './components/RemoteConnectWizard' import type { RemotePrefill } from './lib/remote' +import { isMacOSDesktop } from './lib/useDesktop' export default function App() { const dispatch = useAppDispatch() @@ -275,8 +277,19 @@ function Shell({ activeView }: { activeView: 'chat' | 'automations' | 'cloud-mob
{/* Native title-bar drag strip — hidden in browser, shown in Tauri (CSS). */}