diff --git a/.gitignore b/.gitignore index 4dbcbb952..792146a21 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,4 @@ Thumbs.db # Scratch / agent working dirs (never committed) /tmp/ /.codex/ +.alma-snapshots diff --git a/desktop/Info.dev.plist b/desktop/Info.dev.plist new file mode 100644 index 000000000..cf5e9dd6f --- /dev/null +++ b/desktop/Info.dev.plist @@ -0,0 +1,11 @@ + + + + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + + diff --git a/desktop/app.go b/desktop/app.go index 0cf7808c3..dc562aedc 100644 --- a/desktop/app.go +++ b/desktop/app.go @@ -9,6 +9,7 @@ import ( "errors" "fmt" "io" + "log" "mime" "net/http" "net/url" @@ -39,6 +40,8 @@ import ( "reasonix/internal/plugin" "reasonix/internal/provider" "reasonix/internal/skill" + + "reasonix/desktop/internal/browser" ) // eventChannel is the Wails runtime event name the frontend subscribes to for the @@ -75,6 +78,8 @@ type App struct { tray *desktopTray mediaTokens *mediaTokenStore + + browser *browser.Service botInstalls map[string]*botInstallSession } @@ -244,7 +249,7 @@ func (a *App) workspaceMediaMiddleware() func(http.Handler) http.Handler { // NewApp constructs the bound object. Tabs are restored in startup from the // last session's desktop-tabs.json. func NewApp() *App { - return &App{tabs: map[string]*WorkspaceTab{}, mediaTokens: newMediaTokenStore(), botInstalls: map[string]*botInstallSession{}} + return &App{tabs: map[string]*WorkspaceTab{}, mediaTokens: newMediaTokenStore(), browser: browser.New(), botInstalls: map[string]*botInstallSession{}} } func (a *App) bootContext() context.Context { @@ -269,6 +274,13 @@ func (a *App) startup(ctx context.Context) { a.startTray() go a.restoreOrBuildTabs() + + // Start the browser service in the background (Chrome launch may take time). + go func() { + if err := a.browser.Start(ctx); err != nil { + log.Printf("[browser] failed to start: %v", err) + } + }() } func (a *App) beforeClose(ctx context.Context) bool { @@ -406,10 +418,26 @@ func (a *App) restoreOrBuildTabs() { return } - // First launch: create a default Global tab. - tab := a.createTabEntry("global", globalTabWorkspaceRoot(), "") + // First launch: create a default Global tab with a real topicID so the + // project tree is immediately visible (the old empty-string topicID meant + // ensureTopicIndexed never fired, leaving ListProjectTree with nothing). + topic, err := a.CreateTopic("global", "", "") + if err != nil { + log.Printf("create default global topic: %v", err) + tab := a.createTabEntry("global", globalTabWorkspaceRoot(), "") + tab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx} + tab.TopicTitle = "Global" + a.mu.Lock() + a.tabs[tab.ID] = tab + a.tabOrder = append(a.tabOrder, tab.ID) + a.activeTabID = tab.ID + a.mu.Unlock() + a.startTabControllerBuild(tab) + return + } + tab := a.createTabEntryWithID("global", globalTabWorkspaceRoot(), topic.ID, newTabID()) tab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx} - tab.TopicTitle = "Global" + tab.TopicTitle = topic.Title a.mu.Lock() a.tabs[tab.ID] = tab a.tabOrder = append(a.tabOrder, tab.ID) @@ -468,6 +496,9 @@ func (a *App) shutdown(context.Context) { t.Ctrl.Close() } } + + // Stop the headless browser service. + a.browser.Stop() } // domReady is called (via OnDomReady) after the webview finishes loading its DOM @@ -777,6 +808,9 @@ func (a *App) Compact() error { } // NewSession snapshots the current conversation and rotates to a fresh one. +// The tab's TopicID is cleared so the new blank session does not pollute the +// original topic's session lookup (the old session snapshot retains its topic +// association and remains findable via findTopicSession). func (a *App) NewSession() error { a.mu.RLock() tab := a.activeTabLocked() @@ -789,6 +823,16 @@ func (a *App) NewSession() error { return err } a.persistTabSessionPath(tab, ctrl.SessionPath()) + + // Disassociate the tab from any topic so the new session is not mistaken for + // the original topic when the user later re-opens that topic from the sidebar + // or when findTopicSession scans session files by TopicID. + a.mu.Lock() + tab.TopicID = "" + tab.TopicTitle = "" + a.saveTabsLocked() + a.mu.Unlock() + return nil } @@ -3640,6 +3684,118 @@ func (a *App) RevealPath(path string) error { return revealPath(path) } +// OpenURL opens the given URL in the system default browser. +func (a *App) OpenURL(rawURL string) error { + rawURL = strings.TrimSpace(rawURL) + if rawURL == "" { + return os.ErrInvalid + } + if !strings.HasPrefix(rawURL, "http://") && !strings.HasPrefix(rawURL, "https://") { + rawURL = "https://" + rawURL + } + return exec.Command("open", rawURL).Start() +} + +// --- Browser Control Methods (Wails-bound, auto-registered) --- + +// BrowserNavigate tells the embedded headless browser to navigate to url. +// The url is normalized (https:// prefix added if missing). Returns +// "currentURL|||pageTitle" so the frontend can update the URL bar and tab title. +func (a *App) BrowserNavigate(rawURL string) (string, error) { + rawURL = strings.TrimSpace(rawURL) + if rawURL == "" { + return "", os.ErrInvalid + } + if !strings.HasPrefix(rawURL, "http://") && !strings.HasPrefix(rawURL, "https://") { + rawURL = "https://" + rawURL + } + return a.browser.Navigate(rawURL) +} + +// BrowserBack navigates one step back in history. +func (a *App) BrowserBack() (string, error) { + return a.browser.Back() +} + +// BrowserForward navigates one step forward in history. +func (a *App) BrowserForward() (string, error) { + return a.browser.Forward() +} + +// BrowserRefresh reloads the current page. +func (a *App) BrowserRefresh() (string, error) { + return a.browser.Refresh() +} + +// BrowserScreenshot takes a screenshot of the current page and returns it +// as a data-URL (base64-encoded PNG). +func (a *App) BrowserScreenshot() (string, error) { + return a.browser.Screenshot() +} + +// BrowserEval executes JavaScript in the current page and returns the result. +func (a *App) BrowserEval(js string) (string, error) { + return a.browser.Eval(js) +} + +// BrowserClick clicks on an element matching the given CSS selector. +func (a *App) BrowserClick(selector string) error { + return a.browser.Click(selector) +} + +// BrowserClickAtPoint clicks at specific viewport coordinates (x, y). +func (a *App) BrowserClickAtPoint(x, y float64) error { + return a.browser.ClickAtPoint(x, y) +} + +// BrowserType types text into an element matching the given CSS selector. +func (a *App) BrowserType(selector, text string) error { + return a.browser.Type(selector, text) +} + +// BrowserTypeText types text into the currently focused page element. +// Used by the CDP screenshot interactive mode (user taps the "Type into page" bar). +func (a *App) BrowserTypeText(text string) error { + return a.browser.TypeText(text) +} + +// BrowserScrollDown scrolls the page down by the given number of pixels. +func (a *App) BrowserScrollDown(pixels int) error { + return a.browser.ScrollDown(pixels) +} + +// BrowserCurrentURL returns the current page URL and title as "url|||title". +func (a *App) BrowserCurrentURL() (string, error) { + u, title, err := a.browser.GetCurrentURL() + if err != nil { + return "", err + } + return fmt.Sprintf("%s|||%s", u, title), nil +} + +// BrowserIsRunning reports whether the headless browser service is active. +func (a *App) BrowserIsRunning() bool { + return a.browser.IsRunning() +} + +// BrowserSetViewportSize updates the headless browser viewport to match the +// sidebar iframe display dimensions, so element inspection coordinates are correct. +func (a *App) BrowserSetViewportSize(width, height int) error { + return a.browser.SetViewportSize(width, height) +} + +// BrowserInspectElement finds the DOM element at viewport coordinates (x, y) +// and returns a JSON string with its tag, selector, text, outerHTML, and rect. +// Returns empty string if no element found. +func (a *App) BrowserInspectElement(x, y float64) string { + info, err := a.browser.InspectElement(x, y) + if err != nil || info == nil { + return "" + } + data, _ := json.Marshal(info) + return string(data) +} + func revealPath(path string) error { switch goruntime.GOOS { case "darwin": @@ -3864,11 +4020,12 @@ type MemoryScope struct { // MemoryView is the whole memory panel payload: hierarchical docs, saved facts, // and the writable scopes for the quick-add selector. type MemoryView struct { - Docs []MemoryDoc `json:"docs"` - Facts []MemoryFact `json:"facts"` - Scopes []MemoryScope `json:"scopes"` - StoreDir string `json:"storeDir"` - Available bool `json:"available"` + Docs []MemoryDoc `json:"docs"` + Facts []MemoryFact `json:"facts"` + GlobalFacts []MemoryFact `json:"globalFacts"` + Scopes []MemoryScope `json:"scopes"` + StoreDir string `json:"storeDir"` + Available bool `json:"available"` } // writableScopes are the quick-add targets the panel offers, broad → specific. @@ -3880,7 +4037,7 @@ var writableScopes = []memory.Scope{memory.ScopeUser, memory.ScopeProject, memor func (a *App) Memory() MemoryView { // Always return non-nil slices: a nil Go slice marshals to JSON `null`, which // would crash the panel's `view.facts.length` / `.map`. - view := MemoryView{Docs: []MemoryDoc{}, Facts: []MemoryFact{}, Scopes: []MemoryScope{}} + view := MemoryView{Docs: []MemoryDoc{}, Facts: []MemoryFact{}, GlobalFacts: []MemoryFact{}, Scopes: []MemoryScope{}} a.mu.RLock() ctrl := a.activeCtrlLocked() a.mu.RUnlock() @@ -3901,6 +4058,11 @@ func (a *App) Memory() MemoryView { Name: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Body: f.Body, }) } + for _, f := range set.GlobalStore.List() { + view.GlobalFacts = append(view.GlobalFacts, MemoryFact{ + Name: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Body: f.Body, + }) + } for _, sc := range writableScopes { if p := set.DocPath(sc); p != "" { // user scope yields "" when no config dir view.Scopes = append(view.Scopes, MemoryScope{Scope: string(sc), Path: p}) diff --git a/desktop/docs/git-branch-indicator.md b/desktop/docs/git-branch-indicator.md new file mode 100644 index 000000000..31083360d --- /dev/null +++ b/desktop/docs/git-branch-indicator.md @@ -0,0 +1,49 @@ +# Git 分支指示器 — 方案说明 + +## 需求 + +桌面客户端的右侧面板「改动」视图中,没有显示当前 Git 分支名称。用户希望在工作区改动视图的搜索框上方看到当前分支。 + +## 实现方案 + +### 数据流 + +``` +App.WorkspaceChanges() ← Go 后端绑定方法 + ├─ workspaceGitBranch(base) ← 新增:执行 git branch --show-current + ├─ workspaceGitStatus(base) ← 已有 + └─ WorkspaceChangesView ← 扩展:新增 GitBranch 字段 + → 前端 WorkspacePanel ← 渲染分支指示器 +``` + +### 改动文件 + +| 文件 | 改动 | +|---|---| +| `desktop/workspace_changes.go` | 新增 `workspaceGitBranch()` 函数;在 `WorkspaceChanges()` 中调用并赋值;detached HEAD 时回退显示短 commit | +| `desktop/app.go` | `WorkspaceChangesView` 结构体添加 `GitBranch string` 字段 | +| `desktop/frontend/src/lib/types.ts` | `WorkspaceChangesView` 接口添加 `gitBranch?: string` | +| `desktop/frontend/src/components/WorkspacePanel.tsx` | 搜索框上方插入分支指示器 JSX | +| `desktop/frontend/src/styles.css` | 新增 `.workspace-branch-indicator` 样式 | + +### 渲染位置 + +右侧面板 → 改动 tab → 在 `workspace-files__tools`(标签切换栏)和 `workspace-search`(搜索框)之间插入分支指示器。 + +仅在 `viewMode === "changed"` 且 `changes.gitBranch` 非空时显示。普通分支显示分支名;detached HEAD 显示 `@`。 + +### 效果 + +``` +[GitBranch icon] main +┌─────────────────────┐ +│ 🔍 Search files… │ +└─────────────────────┘ +<改动文件列表> +``` + +## 验证 + +- `go test . -run 'TestWorkspaceChanges|TestParseGitStatusPorcelainZ'` +- `npm run typecheck` +- `npm run check:css` diff --git a/desktop/frontend/dist/.gitkeep b/desktop/frontend/dist/.gitkeep deleted file mode 100644 index 8b1378917..000000000 --- a/desktop/frontend/dist/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/desktop/frontend/public/sounds/mixkit-correct-answer-tone-2870.wav b/desktop/frontend/public/sounds/mixkit-correct-answer-tone-2870.wav new file mode 100644 index 000000000..88a18e152 Binary files /dev/null and b/desktop/frontend/public/sounds/mixkit-correct-answer-tone-2870.wav differ diff --git a/desktop/frontend/public/sounds/mixkit-positive-notification-951.wav b/desktop/frontend/public/sounds/mixkit-positive-notification-951.wav new file mode 100644 index 000000000..d2dd8d802 Binary files /dev/null and b/desktop/frontend/public/sounds/mixkit-positive-notification-951.wav differ diff --git a/desktop/frontend/public/sounds/mixkit-software-interface-back-2575.wav b/desktop/frontend/public/sounds/mixkit-software-interface-back-2575.wav new file mode 100644 index 000000000..3fffc3e64 Binary files /dev/null and b/desktop/frontend/public/sounds/mixkit-software-interface-back-2575.wav differ diff --git a/desktop/frontend/public/sounds/mixkit-software-interface-start-2574.wav b/desktop/frontend/public/sounds/mixkit-software-interface-start-2574.wav new file mode 100644 index 000000000..22d8da974 Binary files /dev/null and b/desktop/frontend/public/sounds/mixkit-software-interface-start-2574.wav differ diff --git a/desktop/frontend/src/App.tsx b/desktop/frontend/src/App.tsx index 34943a560..574af81f8 100644 --- a/desktop/frontend/src/App.tsx +++ b/desktop/frontend/src/App.tsx @@ -4,12 +4,17 @@ import { ShellExpandProvider, useShellExpand } from "./lib/shellExpand"; import { Activity, Command, + Lightbulb, Download, SquarePen, + Globe, FileText, FileJson, GitBranch, + GitCommit, History, + PanelRightClose, + PanelRightOpen, Settings as SettingsIcon, Pencil, Trash2, @@ -18,7 +23,9 @@ import { useToast } from "./lib/toast"; import { asArray } from "./lib/array"; import { clearLegacyLangPref, normalizeLangPref, readLegacyLangPref, t, useI18n, useT } from "./lib/i18n"; import { useController, type Item, type LiveStream } from "./lib/useController"; -import { app, onProjectTreeChanged } from "./lib/bridge"; +import { app, onProjectTreeChanged, onEvent } from "./lib/bridge"; +import { playSuccessChime } from "./lib/sound"; +import { generativeMusic, isGenerativeMusicEnabled } from "./lib/generative-music"; import { Transcript } from "./components/Transcript"; import { Composer } from "./components/Composer"; import { TodoPanel } from "./components/TodoPanel"; @@ -31,7 +38,8 @@ import { CommandPalette, type PaletteItem } from "./components/CommandPalette"; import { SettingsPanel } from "./components/SettingsPanel"; import { UpdateBanner } from "./components/UpdateBanner"; import { ContextPanel } from "./components/ContextPanel"; -import { WorkspacePanel } from "./components/WorkspacePanel"; +import { DockTabBar } from "./components/DockTabBar"; +import { DockContent } from "./components/DockContent"; import { Tooltip } from "./components/Tooltip"; import { StartupSplash, shouldShowStartupSplash } from "./components/StartupSplash"; import { OnboardingOverlay } from "./components/OnboardingOverlay"; @@ -49,6 +57,7 @@ import { normalizeToolApprovalMode, type CollaborationMode, type ComposerInsertRequest, + type DockTab, type Mode, type ProjectNode, type SessionMeta, @@ -73,9 +82,12 @@ import { useWindowStatePersistence } from "./lib/windowState"; import logoWordmark from "./assets/logo-wordmark.svg"; const SIDEBAR_COLLAPSED_KEY = "reasonix.sidebar.collapsed"; +const DEFAULT_YOLO_KEY = "reasonix.defaultYolo"; const SIDEBAR_DEFAULT_WIDTH = 264; -const SIDEBAR_MIN_WIDTH = 248; -const SIDEBAR_MAX_WIDTH = 300; +const SIDEBAR_DEFAULT_RATIO = 0.175; +const SIDEBAR_MIN_WIDTH = 228; +const SIDEBAR_MAX_WIDTH = 420; +const SIDEBAR_COLLAPSED_WIDTH = 48; const SIDEBAR_VIEWPORT_RATIO = 0.18; const CHAT_MIN_WIDTH = 400; const CHAT_DOCKED_MIN_WIDTH = 640; @@ -84,12 +96,22 @@ const WORKSPACE_RESIZER_WIDTH = 8; function isThemeMode(value: string): value is Theme { return value === "auto" || value === "light" || value === "dark"; } -const RIGHT_DOCK_TREE_DEFAULT_WIDTH = 300; -const RIGHT_DOCK_TREE_MIN_WIDTH = 300; -const RIGHT_DOCK_TREE_MAX_WIDTH = 560; -const RIGHT_DOCK_PREVIEW_DEFAULT_WIDTH = 660; +const CONTEXT_PANEL_MIN_WIDTH = 340; +const RIGHT_DOCK_MIN_WIDTH = CONTEXT_PANEL_MIN_WIDTH; +const RIGHT_DOCK_TREE_DEFAULT_WIDTH = 320; +const RIGHT_DOCK_TREE_DEFAULT_RATIO = 0.25; +const RIGHT_DOCK_TREE_MIN_WIDTH = 260; +const RIGHT_DOCK_TREE_MAX_WIDTH = 999999; +const RIGHT_DOCK_PREVIEW_DEFAULT_WIDTH = 640; const RIGHT_DOCK_PREVIEW_MIN_WIDTH = RIGHT_DOCK_PREVIEW_DEFAULT_WIDTH; -const RIGHT_DOCK_MAX_WIDTH = 860; +const RIGHT_DOCK_MAX_WIDTH = 999999; + +const DEFAULT_DOCK_TABS: DockTab[] = [ + { id: "files", type: "files", title: "文件", icon: FileText, closable: false }, + { id: "changes", type: "changes", title: "改动", icon: GitBranch, closable: false }, + { id: "commit", type: "commit", title: "提交", icon: GitCommit, closable: false }, + { id: "memory", type: "memory", title: "记忆", icon: Lightbulb, closable: false }, +]; type RightDockMode = "context" | "files" | "changed"; const SHOW_CONTEXT_DOCK = true; @@ -212,6 +234,22 @@ function topicScopeLabel(tab?: TabMeta): string { return t("scope.project", { name: tab.workspaceName || tab.workspaceRoot || "Project" }); } + + +function normalizeModeValue(mode?: string): Mode { + return mode === "plan" || mode === "yolo" ? mode : "normal"; +} + +function loadDefaultYolo(): boolean { + if (typeof window === "undefined") return false; + try { + return window.localStorage.getItem(DEFAULT_YOLO_KEY) === "1"; + } catch { + return false; + } +} + + function sessionsForScope(sessions: SessionMeta[], filter: HistoryScopeFilter): SessionMeta[] { if (filter.scope === "project") { return sessions.filter((session) => session.scope === "project" && session.workspaceRoot === filter.workspaceRoot); @@ -242,6 +280,7 @@ function workspaceDisplayName(path?: string): string { return parts.length > 0 ? parts[parts.length - 1] : path; } + function materializeLiveItems(items: Item[], live?: LiveStream): Item[] { if (!live) return items; return items.map((item) => { @@ -393,6 +432,7 @@ export default function App() { restoreSession, purgeTrashedSession, renameSession, + markTopicRead, refreshMeta, pickWorkspace, switchWorkspace, @@ -400,10 +440,10 @@ export default function App() { setModel, setEffort, switchTab, - openProjectTab, - openGlobalTab, closeTab, reorderTabs, + openProjectTab, + openGlobalTab, syncActiveTab, } = useController(); const { locale, setPref: setLocalePref } = useI18n(); @@ -413,8 +453,9 @@ export default function App() { const [toolApprovalModesByTab, setToolApprovalModesByTab] = useState>({}); const [goalsByTab, setGoalsByTab] = useState>({}); const [tabMetas, setTabMetas] = useState([]); - const [tabOrderIds, setTabOrderIds] = useState([]); const [tabRevealSignal, setTabRevealSignal] = useState(0); + void tabRevealSignal; + const [startupSplashVisible, setStartupSplashVisible] = useState(() => shouldShowStartupSplash()); // null until the mount probe resolves; true shows the overlay. Probed once — // clearing the key mid-session is the Settings panel's job, not the gate's. @@ -434,8 +475,12 @@ export default function App() { const [contextDetailActive, setContextDetailActive] = useState(false); const [workspacePanelResizing, setWorkspacePanelResizing] = useState(false); const [workspacePanelMaximized, setWorkspacePanelMaximized] = useState(false); + const [dockTabs, setDockTabs] = useState(DEFAULT_DOCK_TABS); + const [activeDockTabId, setActiveDockTabId] = useState("files"); + const [rightDockMode, setRightDockMode] = useState("context"); const [dockRefreshKey, setDockRefreshKey] = useState(0); + const [tabOrderIds, setTabOrderIds] = useState([]); const [projectRevision, setProjectRevision] = useState(0); const [composerInsertRequest, setComposerInsertRequest] = useState(null); const [transientOverlayDismissSignal, setTransientOverlayDismissSignal] = useState(0); @@ -453,6 +498,18 @@ export default function App() { const sidebarTogglePressTimerRef = useRef(null); const workspaceTogglePressTimerRef = useRef(null); + // ⌘K: open the command palette. + useEffect(() => { + const onKey = (e: globalThis.KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === "k" && !e.altKey && !e.shiftKey) { + e.preventDefault(); + setPaletteOpen((o) => !o); + } + }; + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, []); + // Persist window geometry across launches. useWindowStatePersistence(); @@ -565,10 +622,19 @@ export default function App() { const [footerHeight, setFooterHeight] = useState(0); const footerHeightRef = useRef(0); const footerRef = useRef(null); + const [layoutWidth, setLayoutWidth] = useState(0); const rightDockDetailActive = rightDockMode === "context" ? contextDetailActive : workspacePreviewActive; - const preferredWorkspacePanelWidth = rightDockDetailActive ? rightDockPreviewWidth : rightDockTreeWidth; + const preferredWorkspacePanelWidth = + rightDockDetailActive + ? rightDockPreviewWidth + : rightDockTreeWidth; + const sidebarRenderWidth = sidebarCollapsed ? SIDEBAR_COLLAPSED_WIDTH : sidebarWidth; + const measuredMainWidth = layoutWidth > 0 ? Math.max(0, layoutWidth - sidebarRenderWidth) : CHAT_MIN_WIDTH + WORKSPACE_RESIZER_WIDTH + preferredWorkspacePanelWidth; const workspacePanelMinWidth = rightDockDetailActive ? RIGHT_DOCK_PREVIEW_MIN_WIDTH : RIGHT_DOCK_TREE_MIN_WIDTH; + const budget = Math.max(0, measuredMainWidth - CHAT_MIN_WIDTH - WORKSPACE_RESIZER_WIDTH); + const workspacePanelFloating = workspacePanelOpen && !workspacePanelMaximized && budget < workspacePanelMinWidth; + const resolvedWorkspacePanelWidth = workspacePanelOpen && !workspacePanelMaximized ? Math.max(workspacePanelMinWidth, preferredWorkspacePanelWidth) : preferredWorkspacePanelWidth; @@ -630,13 +696,19 @@ export default function App() { }); }, [tabMetas]); + useEffect(() => { const ids = new Set(tabMetas.map((tab) => tab.id)); + const defaultYolo = loadDefaultYolo(); setModesByTab((current) => { let changed = false; const next: Record = {}; for (const tab of tabMetas) { - const mode = normalizeMode(tab.mode); + let mode = normalizeMode(tab.mode); + // If defaultYolo is on and this is a brand-new tab (not in current), flip it to yolo + if (defaultYolo && !(tab.id in current) && mode === "normal") { + mode = "yolo"; + } next[tab.id] = mode; if (current[tab.id] !== mode) changed = true; } @@ -819,6 +891,70 @@ export default function App() { const todos = useMemo(() => (todoItem ? parseTodos(todoItem.args) : []), [todoItem]); const [dismissedTodo, setDismissedTodo] = useState(null); const showTodos = shouldShowTodoPanel(todoItem?.id, dismissedTodo, todos); + const [todoNow, setTodoNow] = useState(() => Date.now()); + const todoSeenRef = useRef<{ id: string; at: number } | null>(null); + + useEffect(() => { + if (!todoItem) { + todoSeenRef.current = null; + return; + } + if (todoSeenRef.current?.id !== todoItem.id) { + todoSeenRef.current = { id: todoItem.id, at: Date.now() }; + setTodoNow(Date.now()); + } + }, [todoItem]); + + useEffect(() => { + if (!showTodos) return; + const id = window.setInterval(() => setTodoNow(Date.now()), 15000); + return () => window.clearInterval(id); + }, [showTodos]); + + // Play a success chime when any turn finishes (agent:turn-done event). + useEffect(() => { + const unsub = onEvent((e) => { + if (e.kind === "turn_done") { + playSuccessChime(); + } + }); + return unsub; + }, []); + + // Play generative ambient music while the agent is generating. + // Start/stop the AudioContext engine based on running state. + useEffect(() => { + if (state.running && isGenerativeMusicEnabled()) { + generativeMusic.start(); + } else { + generativeMusic.stop(); + } + }, [state.running]); + + // Per-token: play a note every time a text/reasoning chunk arrives. + // playTokenNote() is a no-op when the engine isn't running. + useEffect(() => { + const unsub = onEvent((e) => { + if (e.kind === "text" || e.kind === "reasoning" || e.kind === "tool_dispatch") { + generativeMusic.playTokenNote(); + } + }); + return unsub; + }, []); + + const todoStale = useMemo(() => { + if (!showTodos || !todoEntry) return false; + const after = state.items.slice(todoEntry.index + 1); + const completedToolsAfter = after.filter( + (it) => it.kind === "tool" && it.name !== "todo_write" && !it.parentId && (it.status === "done" || it.status === "error"), + ).length; + const finalAssistantAfter = after.some((it) => it.kind === "assistant" && !it.streaming && it.text.trim() !== ""); + const readinessNoticeAfter = after.some( + (it) => it.kind === "notice" && /final-answer readiness|todo_write|complete_step/i.test(it.text), + ); + const staleByTime = state.running && todoSeenRef.current?.id === todoEntry.item.id && todoNow - todoSeenRef.current.at > 90_000; + return completedToolsAfter >= 2 || finalAssistantAfter || readinessNoticeAfter || staleByTime; + }, [showTodos, state.items, state.running, todoEntry, todoNow]); const sessionTitle = topicTitle(activeTab); const sessionHasContent = state.items.length > 0 || Boolean(state.live?.text || state.live?.reasoning); @@ -1138,18 +1274,25 @@ export default function App() { const startX = event.clientX; const startDockWidth = preferredWorkspacePanelWidth; let nextDockWidth = startDockWidth; + // Write CSS variable directly during drag to avoid React re-render on + // every pixel. Only state + localStorage are committed on pointerup. + const root = document.documentElement; + root.style.setProperty("--workspace-width", `${startDockWidth}px`); const onMove = (moveEvent: PointerEvent) => { const delta = moveEvent.clientX - startX; nextDockWidth = startDockWidth - delta; + // During drag, update both CSS var and state for responsive feedback if (rightDockDetailActive) { setRightDockPreviewWidth(clampRightDockPreviewWidth(nextDockWidth)); } else { setRightDockTreeWidth(clampRightDockTreeWidth(nextDockWidth)); } + root.style.setProperty("--workspace-width", `${Math.round(nextDockWidth)}px`); }; const onDone = () => { setSavedWorkspacePanelWidth(nextDockWidth); setWorkspacePanelResizing(false); + root.style.removeProperty("--workspace-width"); window.removeEventListener("pointermove", onMove); window.removeEventListener("pointerup", onDone); window.removeEventListener("pointercancel", onDone); @@ -1181,36 +1324,83 @@ export default function App() { [preferredWorkspacePanelWidth, rightDockDetailActive, setSavedWorkspacePanelWidth], ); - const openWorkspacePanel = useCallback( - (mode: RightDockMode = rightDockMode) => { - closeTransientOverlays(); - if (mode !== rightDockMode) { - setWorkspacePreviewActive(false); - setContextDetailActive(false); - } else if (mode === "context") { - setWorkspacePreviewActive(false); - } else { - setContextDetailActive(false); - } - setRightDockMode(mode); - let nextMaximized = workspacePanelMaximized; - if (mode === "context") { - nextMaximized = false; - setWorkspacePanelMaximized(false); - } else { - // When user explicitly opens the panel, we do NOT force maximize. - // If there's not enough room, the panel will open in floating mode - // over the chat area, preserving the chat area's minimum width - // and keeping the panel's close button accessible. - nextMaximized = false; + const activateDockTab = useCallback( + (id: string) => { + const tab = dockTabs.find((t) => t.id === id); + if (tab) { + setActiveDockTabId(id); + setWorkspacePanelOpen(true); setWorkspacePanelMaximized(false); } - if (workspacePanelOpen && workspacePanelMaximized === nextMaximized) { + }, + [dockTabs], + ); + + const closeDockTab = useCallback( + (id: string) => { + setDockTabs((prev) => { + const idx = prev.findIndex((t) => t.id === id); + const next = prev.filter((t) => t.id !== id); + if (next.length === 0) { + setWorkspacePanelOpen(false); + } else if (activeDockTabId === id) { + const newIdx = Math.min(idx, next.length - 1); + setActiveDockTabId(next[newIdx].id); + } + return next; + }); + }, + [activeDockTabId], + ); + + const addBrowserTab = useCallback(() => { + const id = `browser_${Date.now()}`; + const newTab: DockTab = { + id, + type: "browser", + title: "浏览器", + icon: Globe, + closable: true, + metadata: { url: "about:blank", history: [], historyIndex: -1, isLoading: false }, + }; + setDockTabs((prev) => [...prev, newTab]); + setActiveDockTabId(id); + setWorkspacePanelOpen(true); + setWorkspacePanelMaximized(false); + }, []); + + const handleDockMetadataUpdate = useCallback( + (id: string, meta: Record) => { + setDockTabs((prev) => + prev.map((t) => + t.id === id ? { ...t, metadata: { ...((t.metadata ?? {}) as Record), ...meta } } : t, + ), + ); + }, + [], + ); + + const handleDockTitleUpdate = useCallback( + (id: string, title: string) => { + setDockTabs((prev) => prev.map((t) => (t.id === id ? { ...t, title } : t))); + }, + [], + ); + + const openWorkspacePanel = useCallback( + (mode: string) => { + const existing = dockTabs.find((t) => t.id === mode || t.type === mode); + if (existing) { + setActiveDockTabId(existing.id); + setWorkspacePanelOpen(true); + setWorkspacePanelMaximized(false); return; } - setWorkspacePanelOpen(true); + if (mode === "browser") { + addBrowserTab(); + } }, - [closeTransientOverlays, rightDockMode, workspacePanelMaximized, workspacePanelOpen], + [dockTabs, addBrowserTab], ); const closeWorkspacePanel = useCallback(() => { @@ -1255,7 +1445,6 @@ export default function App() { }, [closeTransientOverlays, contextDetailActive], ); - const layoutStyle = useMemo( () => ({ @@ -1270,11 +1459,14 @@ export default function App() { const setWorkspacePanel = useCallback((open: boolean) => { if (open) { - openWorkspacePanel(); + if (activeDockTabId) { + setWorkspacePanelOpen(true); + setWorkspacePanelMaximized(false); + } } else { closeWorkspacePanel(); } - }, [closeWorkspacePanel, openWorkspacePanel]); + }, [activeDockTabId, closeWorkspacePanel]); const addWorkspaceTextToComposer = useCallback((text: string) => { setComposerInsertRequest({ id: Date.now(), text }); @@ -1359,7 +1551,6 @@ export default function App() { if (scope === "fork") { await refreshTabMetas(); setProjectRevision((value) => value + 1); - setTabRevealSignal((signal) => signal + 1); return; } if (scope === "code" || scope === "both") { @@ -1375,9 +1566,76 @@ export default function App() { } else { await openProjectTab(workspaceRoot, topicId); } + if (topicId) await markTopicRead(topicId); await refreshTabMetas(); + setDockRefreshKey((v) => v + 1); setTabRevealSignal((signal) => signal + 1); - }, [closeTransientOverlays, openGlobalTab, openProjectTab, refreshTabMetas]); + }, [closeTransientOverlays, markTopicRead, openGlobalTab, openProjectTab, refreshTabMetas, setDockRefreshKey]); + + // ⌘G: jump to the next unread topic that is not currently running + useEffect(() => { + const onKeyDown = (event: globalThis.KeyboardEvent) => { + if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey || event.key.toLowerCase() !== "g") return; + event.preventDefault(); + + const findNextUnread = async () => { + try { + const tree = await app.ListProjectTree(); + const nodes = asArray(tree); + + // Flatten: collect topic / global_topic nodes in tree order + const topics: { scope: string; root: string; topicId: string; running?: boolean; hasUnread?: boolean }[] = []; + for (const node of nodes) { + const children = asArray(node.children); + for (const child of children) { + if (child.kind === "topic" || child.kind === "global_topic") { + topics.push({ + scope: child.kind === "global_topic" ? "global" : "project", + root: child.root ?? "", + topicId: child.topicId ?? "", + running: child.running, + hasUnread: child.hasUnread, + }); + } + } + } + + if (topics.length === 0) return; + + // Start looking after the current topic (or from the beginning) + const currentId = activeTab?.topicId; + const startIndex = currentId ? topics.findIndex((t) => t.topicId === currentId) + 1 : 0; + + // Priority 1: unread + not running (stopped generating but unread) + for (let i = 0; i < topics.length; i++) { + const idx = (startIndex + i) % topics.length; + const topic = topics[idx]; + if (topic.hasUnread && !topic.running) { + await handleOpenTopic(topic.scope, topic.root, topic.topicId); + return; + } + } + + // Priority 2: running (currently generating) + for (let i = 0; i < topics.length; i++) { + const idx = (startIndex + i) % topics.length; + const topic = topics[idx]; + if (topic.running) { + await handleOpenTopic(topic.scope, topic.root, topic.topicId); + return; + } + } + } catch { + /* bridge unavailable */ + } + }; + + void findNextUnread(); + }; + + window.addEventListener("keydown", onKeyDown, { capture: true }); + return () => window.removeEventListener("keydown", onKeyDown, { capture: true }); + }, [activeTab, handleOpenTopic]); // History drawer: project menus can open a scoped saved-session list. Idle row // clicks resume; running row clicks only preview through PreviewSession. @@ -1399,6 +1657,53 @@ export default function App() { setHistView(null); }, [closeTransientOverlays]); + const buildPaletteItems = useCallback((): PaletteItem[] => { + const items: PaletteItem[] = []; + + for (const tab of tabMetas) { + items.push({ + id: `tab:${tab.id}`, + title: tab.topicTitle || "Untitled", + hint: tab.scope === "global" ? "Global" : tab.workspaceName || tab.workspaceRoot, + group: t("sidebar.conversations"), + keywords: [tab.label], + run: () => void handleTabChange(tab.id), + }); + } + + items.push( + { + id: "action:new-session", + title: t("topbar.newSession"), + group: "Actions", + keywords: ["new", "chat", "session"], + run: () => void handleNewTab(), + }, + { + id: "action:history", + title: t("sidebar.allHistory"), + group: "Actions", + keywords: ["history", "sessions", "past"], + run: () => void openAllHistory(), + }, + { + id: "action:settings", + title: t("topbar.settings"), + group: "Actions", + keywords: ["preferences", "config", "options"], + run: () => setSettingsTarget("general"), + }, + { + id: "action:trash", + title: t("sidebar.trash"), + group: "Actions", + keywords: ["deleted", "bin"], + run: () => void openTrash(), + }, + ); + + return items; + }, [tabMetas, handleTabChange, handleNewTab, openAllHistory, openTrash, t]); const onResumeSession = useCallback( async (session: SessionMeta) => { if (state.running) return; @@ -1416,7 +1721,6 @@ export default function App() { } setHistView(null); await resumeSession(session.path, targetTab.id); - await refreshTabMetas(); setTabRevealSignal((signal) => signal + 1); } catch (err: any) { setHistView(null); @@ -1594,11 +1898,11 @@ export default function App() { } }, [renameTopic, renamingTopicId, topicTitleDraft]); - const sidebarExpandBlocked = false; const sidebarToggleTitle = sidebarCollapsed ? t("sidebar.expand") : t("sidebar.collapse"); const sidebarNavTooltipDisabled = !sidebarCollapsed; + const sidebarExpandBlocked = false; const browserPreviewChrome = typeof window !== "undefined" && !window.runtime; const workspacePanelResetWidth = rightDockDetailActive ? RIGHT_DOCK_PREVIEW_DEFAULT_WIDTH @@ -1610,6 +1914,7 @@ export default function App() { const topicbarSubtitleVisible = Boolean(topicbarWorkspaceLabel); const topicbarSubtitleTitle = topicbarWorkspacePath || topicbarWorkspaceLabel; + return ( @@ -1733,7 +2038,7 @@ export default function App() { />
- <> +
@@ -1836,6 +2141,29 @@ export default function App() { {t("topicBar.command")} + {!workspacePanelMaximized && (workspacePanelRenderable ? ( + + + + ) : ( + + + + ))}
@@ -1866,7 +2194,7 @@ export default function App() {
- {showTodos && setDismissedTodo(todoItem!.id)} />} + {showTodos && setDismissedTodo(todoItem!.id)} />} {state.approval && (
-
{workspacePanelGridOpen && ( @@ -1964,56 +2291,19 @@ export default function App() { {workspacePanelRenderable && ( diff --git a/desktop/frontend/src/components/ApprovalModal.tsx b/desktop/frontend/src/components/ApprovalModal.tsx index 4a9920c6d..167543ae2 100644 --- a/desktop/frontend/src/components/ApprovalModal.tsx +++ b/desktop/frontend/src/components/ApprovalModal.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { useT } from "../lib/i18n"; import type { WireApproval } from "../lib/types"; import { PromptAction, PromptDetailToggle, PromptShelf } from "./PromptShelf"; +import { playAttentionChime } from "../lib/sound"; export function ApprovalModal({ approval, @@ -48,6 +49,7 @@ export function ApprovalModal({ setRevisionOpen(false); setRevisionText(""); setDetailsOpen(false); + playAttentionChime(); }, [approval.id]); useEffect(() => { diff --git a/desktop/frontend/src/components/AskCard.tsx b/desktop/frontend/src/components/AskCard.tsx index 028c1e625..8f9c2854c 100644 --- a/desktop/frontend/src/components/AskCard.tsx +++ b/desktop/frontend/src/components/AskCard.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { useT } from "../lib/i18n"; import type { QuestionAnswer, WireAsk, WireAskQuestion } from "../lib/types"; import { PromptAction, PromptBadge, PromptDetailToggle, PromptShelf } from "./PromptShelf"; +import { playAttentionChime } from "../lib/sound"; // AskCard renders the `ask` tool as a compact prompt shelf near the composer. It // walks multi-question asks one at a time; single-select answers advance @@ -37,6 +38,7 @@ export function AskCard({ setActive(0); setDetailsOpen(false); if (advanceTimer.current != null) window.clearTimeout(advanceTimer.current); + playAttentionChime(); }, [ask.id]); useEffect(() => { diff --git a/desktop/frontend/src/components/BrowserPanel.tsx b/desktop/frontend/src/components/BrowserPanel.tsx new file mode 100644 index 000000000..e321df3b6 --- /dev/null +++ b/desktop/frontend/src/components/BrowserPanel.tsx @@ -0,0 +1,234 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { ArrowLeft, ArrowRight, ExternalLink, Globe, Loader2, RefreshCw, RotateCcw } from "lucide-react"; +import { app } from "../lib/bridge"; +import { useT } from "../lib/i18n"; +import type { DockTab } from "../lib/types"; + +interface BrowserPanelProps { + tab: DockTab; + onMetadataUpdate: (id: string, meta: Record) => void; + onTitleUpdate: (id: string, title: string) => void; +} + +function normalizeURL(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed) return ""; + if (/^https?:\/\//i.test(trimmed)) return trimmed; + // Support common localhost/private patterns without a protocol. + if (/^localhost[:\/]|^127\.|^0\.|^10\.|^172\.(1[6-9]|2\d|3[01])\.|^192\.168\./.test(trimmed)) { + return `http://${trimmed}`; + } + return `https://${trimmed}`; +} + +const DEFAULT_HOMEPAGE = "about:blank"; + +export function BrowserPanel({ tab, onMetadataUpdate, onTitleUpdate }: BrowserPanelProps) { + const t = useT(); + + const meta = (tab.metadata ?? { url: DEFAULT_HOMEPAGE, history: [], historyIndex: -1, isLoading: false }) as { + url: string; + history: string[]; + historyIndex: number; + isLoading: boolean; + }; + + const [urlInput, setUrlInput] = useState(meta.url === DEFAULT_HOMEPAGE ? "" : meta.url); + const [currentUrl, setCurrentUrl] = useState(meta.url); + const [isLoading, setIsLoading] = useState(false); + const [isCdpReady, setIsCdpReady] = useState(false); + const iframeRef = useRef(null); + const currentUrlRef = useRef(currentUrl); + + useEffect(() => { + currentUrlRef.current = currentUrl; + }, [currentUrl]); + + // Check CDP readiness on mount. + useEffect(() => { + app.BrowserIsRunning().then(setIsCdpReady).catch(() => {}); + }, []); + + // --- Navigation (iframe-based) --- + + const navigateTo = useCallback( + (target: string) => { + const normalized = normalizeURL(target); + if (!normalized) return; + setUrlInput(normalized); + setCurrentUrl(normalized); + setIsLoading(true); + onMetadataUpdate(tab.id, { url: normalized, isLoading: true }); + // Also tell the CDP backend so AI can follow along. + app.BrowserNavigate(normalized).catch(() => {}); + }, + [tab.id, onMetadataUpdate], + ); + + const handleGo = useCallback(() => { + navigateTo(urlInput); + }, [navigateTo, urlInput]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + navigateTo(urlInput); + } + }, + [navigateTo, urlInput], + ); + + const handleRefresh = useCallback(() => { + if (iframeRef.current && currentUrl !== DEFAULT_HOMEPAGE) { + setIsLoading(true); + iframeRef.current.src = currentUrl; + app.BrowserRefresh().catch(() => {}); + } + }, [currentUrl]); + + const handleBack = useCallback(() => { + try { + iframeRef.current?.contentWindow?.history.back(); + } catch { /* cross-origin */ } + app.BrowserBack().catch(() => {}); + }, []); + + const handleForward = useCallback(() => { + try { + iframeRef.current?.contentWindow?.history.forward(); + } catch { /* cross-origin */ } + app.BrowserForward().catch(() => {}); + }, []); + + const handleOpenInSystem = useCallback(() => { + if (currentUrl && currentUrl !== DEFAULT_HOMEPAGE) { + void app.OpenURL(currentUrl); + } + }, [currentUrl]); + + // --- Iframe load: sync URL bar + title when same-origin --- + + const handleIframeLoad = useCallback(() => { + setIsLoading(false); + onMetadataUpdate(tab.id, { isLoading: false }); + + try { + const iframe = iframeRef.current; + if (!iframe?.contentWindow) return; + + const doc = iframe.contentDocument ?? iframe.contentWindow.document; + const title = doc.title || ""; + const href = doc.URL || currentUrlRef.current; + + if (title) { + onTitleUpdate(tab.id, title); + } + + if (href && href !== DEFAULT_HOMEPAGE && href !== "about:blank" && href !== currentUrlRef.current) { + setCurrentUrl(href); + setUrlInput(href); + onMetadataUpdate(tab.id, { url: href }); + } + } catch { + // Cross-origin — can't read doc props; that's normal. + } + }, [tab.id, onMetadataUpdate, onTitleUpdate]); + + // --- Open from external (metadata.openUrl trigger) --- + + useEffect(() => { + const storedUrl = (tab.metadata as Record)?.openUrl as string | undefined; + if (storedUrl && storedUrl !== currentUrlRef.current) { + const normalized = normalizeURL(storedUrl); + if (normalized) { + setUrlInput(normalized); + setCurrentUrl(normalized); + setIsLoading(true); + } + onMetadataUpdate(tab.id, { openUrl: undefined }); + } + }, [tab.metadata, tab.id, onMetadataUpdate]); + + // Drive iframe src when currentUrl changes (avoids re-setting the same URL). + useEffect(() => { + if (currentUrl && currentUrl !== DEFAULT_HOMEPAGE && iframeRef.current) { + try { + const iframeDoc = iframeRef.current.contentDocument ?? iframeRef.current.contentWindow?.document; + if (iframeDoc && iframeDoc.URL !== currentUrl) { + iframeRef.current.src = currentUrl; + } + } catch { + iframeRef.current.src = currentUrl; + } + } + }, [currentUrl]); + + const showBrowser = currentUrl && currentUrl !== DEFAULT_HOMEPAGE; + + return ( +
+ {/* ── Navigation bar ── */} +
+ + + +
+ setUrlInput(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={t("rightDock.browserUrlPlaceholder")} + /> +
+ + {showBrowser && ( + + )} +
+ + {/* ── Iframe browser content ── */} +
+ {showBrowser ? ( +