Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions desktop/frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -828,6 +828,7 @@ export default function App() {
const {
state,
activeTabId,
allStates,
send,
sendToTab,
runShell,
Expand Down Expand Up @@ -1389,6 +1390,46 @@ export default function App() {
[sessionTitle, state.items, state.live],
);

const handleCopyTab = useCallback(async (tabId: string) => {
const states = allStates.current;
const tabState = states.get(tabId);
if (!tabState || (tabState.items.length === 0 && !tabState.live)) return;
const tabMeta = tabMetas.find((t) => t.id === tabId);
const title = tabMeta ? topicTitle(tabMeta) : "";
const markdown = sessionItemsToMarkdown(title, tabState.items, tabState.live);
try {
await navigator.clipboard.writeText(markdown);
} catch {
try {
await window.runtime?.ClipboardSetText?.(markdown);
} catch {
/* clipboard unavailable */
}
}
}, [allStates, tabMetas]);

const handleCopyAllTabs = useCallback(async () => {
const states = allStates.current;
const parts: string[] = [];
for (const tab of tabMetas) {
const tabState = states.get(tab.id);
if (!tabState || (tabState.items.length === 0 && !tabState.live)) continue;
const title = topicTitle(tab);
parts.push("## " + title + "\n\n" + sessionItemsToMarkdown(title, tabState.items, tabState.live));
}
if (parts.length === 0) return;
const combined = parts.join("\n\n---\n\n");
try {
await navigator.clipboard.writeText(combined);
} catch {
try {
await window.runtime?.ClipboardSetText?.(combined);
} catch {
/* clipboard unavailable */
}
}
}, [allStates, tabMetas]);

useEffect(() => {
if (!topicExportOpen) return;
const onDown = (event: MouseEvent) => {
Expand Down Expand Up @@ -2407,6 +2448,8 @@ export default function App() {
onTabsReorder={(ids) => void handleTabsReorder(ids)}
onNewTab={() => void handleNewTab()}
onOpenPalette={() => void openPalette()}
onCopyTab={(id) => void handleCopyTab(id)}
onCopyAllTabs={() => void handleCopyAllTabs()}
/>
)}
<a className="skip-to-composer" href="#composer-input">
Expand Down
6 changes: 6 additions & 0 deletions desktop/frontend/src/components/AppChrome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ interface AppChromeProps {
onTabsReorder: (tabIds: string[]) => void;
onNewTab: () => void;
onOpenPalette: () => void;
onCopyTab?: (tabId: string) => void;
onCopyAllTabs?: () => void;
}

export function AppChrome({
Expand All @@ -55,6 +57,8 @@ export function AppChrome({
onTabsReorder,
onNewTab,
onOpenPalette,
onCopyTab,
onCopyAllTabs,
}: AppChromeProps) {
const t = useT();
const darwinChrome = platform === "darwin";
Expand All @@ -79,6 +83,8 @@ export function AppChrome({
onTabsReorder={onTabsReorder}
onNewTab={onNewTab}
onOpenPalette={undefined}
onCopyTab={onCopyTab}
onCopyAllTabs={onCopyAllTabs}
commandCompact={commandCompact}
/>
);
Expand Down
41 changes: 39 additions & 2 deletions desktop/frontend/src/components/TabBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// open project/global topic, so switching tabs switches the active conversation.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties, DragEvent, KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent } from "react";
import { FileText, Plus, Search, X } from "lucide-react";
import { Copy, FileText, Plus, Search, X } from "lucide-react";
import { normalizeCollaborationMode, normalizeMode, normalizeToolApprovalMode, type Mode, type TabMeta } from "../lib/types";
import { projectColorValue } from "../lib/projectColors";
import { useT } from "../lib/i18n";
Expand All @@ -18,6 +18,8 @@ interface TabBarProps {
onTabsReorder: (tabIds: string[]) => void;
onNewTab: () => void;
onOpenPalette?: () => void;
onCopyTab?: (tabId: string) => void;
onCopyAllTabs?: () => void;
commandCompact?: boolean;
revealActiveSignal?: number;
}
Expand Down Expand Up @@ -52,7 +54,7 @@ function projectAccentStyle(color?: string): CSSProperties | undefined {
return { "--project-accent": value } as CSSProperties;
}

export function TabBar({ tabs, activeTabId, onTabChange, onTabClose, onTabsClose, onTabsReorder, onNewTab, onOpenPalette, commandCompact = false, revealActiveSignal = 0 }: TabBarProps) {
export function TabBar({ tabs, activeTabId, onTabChange, onTabClose, onTabsClose, onTabsReorder, onNewTab, onOpenPalette, onCopyTab, onCopyAllTabs, commandCompact = false, revealActiveSignal = 0 }: TabBarProps) {
const t = useT();
const [draggingTabId, setDraggingTabId] = useState<string | null>(null);
const [dropTarget, setDropTarget] = useState<{ id: string; side: DropSide } | null>(null);
Expand Down Expand Up @@ -179,6 +181,41 @@ export function TabBar({ tabs, activeTabId, onTabChange, onTabClose, onTabsClose
closeTabsFromMenu(rightTabIds, nextActiveTabId);
},
},
...(onCopyTab || onCopyAllTabs
? [
{
type: "separator" as const,
key: "sep-copy",
},
...(onCopyTab
? [
{
key: "copy-tab",
icon: <Copy size={13} />,
label: t("tabBar.copyTab"),
onSelect: () => {
closeTabMenu();
onCopyTab(menuTabId);
},
},
]
: []),
...(onCopyAllTabs
? [
{
key: "copy-all-tabs",
icon: <Copy size={13} />,
label: t("tabBar.copyAllTabs"),
disabled: tabs.length <= 1,
onSelect: () => {
closeTabMenu();
onCopyAllTabs();
},
},
]
: []),
]
: []),
]
: [];

Expand Down
1 change: 1 addition & 0 deletions desktop/frontend/src/lib/useController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1283,6 +1283,7 @@ export function useController() {
return {
state: activeState,
activeTabId,
allStates: statesRef,
send, sendToTab, runShell, steer, notice, cancel, approve, answerQuestion, setControllerMode, setCollaborationMode, setToolApprovalMode, setGoal, clearGoal,
newSession, clearSession, listSessions, listTrashedSessions, resumeSession, openChannelSession, previewSession, deleteSession, restoreSession, purgeTrashedSession, renameSession,
refreshMeta, pickWorkspace, switchWorkspace, compact, rewind, setModel, setEffort, setTokenMode,
Expand Down
2 changes: 2 additions & 0 deletions desktop/frontend/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ export const en = {
"tabBar.closeTab": "Close tab",
"tabBar.closeOtherTabs": "Close other tabs",
"tabBar.closeTabsToRight": "Close tabs to right",
"tabBar.copyTab": "Copy this tab",
"tabBar.copyAllTabs": "Copy all tabs",
"tabBar.newSession": "New session",
"tabBar.tabActions": "Tab actions",
"tabBar.commandSearch": "Search · Command · Open file",
Expand Down
2 changes: 2 additions & 0 deletions desktop/frontend/src/locales/zh-TW.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1061,6 +1061,8 @@ export const zhTW: Record<DictKey, string> = {
"tabBar.closeTab": "關閉標籤頁",
"tabBar.closeOtherTabs": "關閉其他標籤頁",
"tabBar.closeTabsToRight": "關閉右側標籤頁",
"tabBar.copyAllTabs": "複製所有標籤頁",
"tabBar.copyTab": "複製此標籤頁",
"tabBar.newSession": "新建會話",
"tabBar.tabActions": "標籤頁操作",
"tabBar.commandSearch": "搜尋 · 命令 · 開啟檔案",
Expand Down
2 changes: 2 additions & 0 deletions desktop/frontend/src/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ export const zh: Record<DictKey, string> = {
"tabBar.closeTab": "关闭标签页",
"tabBar.closeOtherTabs": "关闭其他标签页",
"tabBar.closeTabsToRight": "关闭右侧标签页",
"tabBar.copyAllTabs": "复制所有标签页",
"tabBar.copyTab": "复制此标签页",
"tabBar.newSession": "新建会话",
"tabBar.tabActions": "标签页操作",
"tabBar.commandSearch": "搜索 · 命令 · 打开文件",
Expand Down
33 changes: 30 additions & 3 deletions internal/control/auto_plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,17 +69,44 @@ func (c *Controller) shouldAutoPlan(ctx context.Context, input string) bool {
}

// TaskWarrantsPlanner reports whether a task turn is worth a planner pass in
// two-model mode. Empty input, slash commands, and low-risk informational asks
// (explain / show / what / why / 解释 / 查一下 …) skip straight to the executor;
// anything that reads like a work request — even a terse one — still gets planned.
// two-model mode. Empty input, slash commands, low-risk informational asks
// (explain / show / what / why / 解释 / 查一下 …), and synthetic system
// messages (goal loop turns, readiness retries, compaction summaries, etc.)
// skip straight to the executor; anything that reads like a work request —
// even a terse one — still gets planned.
func TaskWarrantsPlanner(input string) bool {
text := strings.TrimSpace(agent.StripTransientUserBlocks(input))
// Strip active-goal block injected by Compose for goal-mode turns.
text = stripActiveGoalBlock(text)
if text == "" || strings.HasPrefix(text, "/") || strings.HasPrefix(text, PlanModeMarker) {
return false
}
if IsSyntheticUserMessage(text) {
return false
}
return !isLowRiskQuestion(strings.ToLower(text))
}

// stripActiveGoalBlock removes the <active-goal>...</active-goal> wrapper that
// Compose prepends to goal-mode user turns, so the remaining text can be
// checked against synthetic message patterns.
func stripActiveGoalBlock(text string) string {
open := "<active-goal>"
close := "</active-goal>"
if !strings.Contains(text, open) {
return text
}
if idx := strings.Index(text, open); idx >= 0 {
if end := strings.Index(text, close); end >= 0 {
after := strings.TrimSpace(text[end+len(close):])
if after != "" {
return after
}
}
}
return text
}

func autoPlanScore(input string) int {
text := strings.TrimSpace(input)
if text == "" || strings.HasPrefix(text, "/") || strings.HasPrefix(text, PlanModeMarker) {
Expand Down
5 changes: 5 additions & 0 deletions internal/control/auto_plan_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ func TestTaskWarrantsPlanner(t *testing.T) {
{"fix the bug", true}, // terse, but a work request → still planned
{"add a login button", true}, // ditto
{"implement the new caching layer across the backend", true},
// Synthetic goal-loop messages should NOT trigger the planner.
{activeGoalBlock("execute plan: fix the parser", GoalResearchAuto) + "\n\n" + goalContinueTurn, false},
{activeGoalBlock("execute plan: fix the parser", GoalResearchAuto) + "\n\n" + goalSelfCheckTurn, false},
// Normal user input inside an active-goal block should still trigger.
{activeGoalBlock("implement the new caching layer", GoalResearchAuto) + "\n\nimplement the new caching layer across the backend", true},
}
for _, c := range cases {
if got := TaskWarrantsPlanner(c.input); got != c.want {
Expand Down
56 changes: 46 additions & 10 deletions internal/control/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,8 @@ func New(opts Options) *Controller {
})
c.executor.SetMemoryQueue(c)
}
// Check for persisted goal state and notify on session start.
c.notifyGoalState()
return c
}

Expand Down Expand Up @@ -952,6 +954,36 @@ type goalState struct {
Todos []evidence.TodoItem `json:"todos,omitempty"`
}

// notifyGoalState checks for a persisted goal state on session start and emits
// a notice if an uncompleted goal exists.
func (c *Controller) notifyGoalState() {
path := c.goalStatePath
if path == "" {
return
}
data, err := os.ReadFile(path)
if err != nil {
return
}
var state goalState
if err := json.Unmarshal(data, &state); err != nil {
return
}
if state.Goal == "" {
return
}
switch state.Status {
case GoalStatusRunning:
c.notice(fmt.Sprintf("detected uncompleted goal: \"%s\" — /goal to view or /goal clear to discard", ShortGoalForNotice(state.Goal)))
case GoalStatusBlocked:
reason := state.Block
if reason == "" {
reason = "unknown"
}
c.notice(fmt.Sprintf("detected blocked goal: \"%s\" (blocked: %s) — /goal to view or /goal clear to discard", ShortGoalForNotice(state.Goal), reason))
}
}

// lastAssistantText returns the content of the most recent assistant message with
// non-empty text — the model's final answer for the turn (its plan, in plan mode).
func lastAssistantText(msgs []provider.Message) string {
Expand Down Expand Up @@ -1317,7 +1349,7 @@ func (c *Controller) applyPlanExec(input, display string) {
if len(modules) > 0 {
b.WriteString("## Project modules detected\n\n")
for _, m := range modules {
fmt.Fprintf(&b, "- %s/", m)
fmt.Fprintf(&b, "- %s/\n", m)
}
b.WriteString("\n\nRoute steps to the module they belong to. Steps in different modules can run in parallel.\n\n")
}
Expand Down Expand Up @@ -3869,17 +3901,21 @@ func (c *Controller) emitRememberResult(r RememberResult) {
// detectProjectModules scans the workspace root for top-level source directories
// to enable module-aware task routing in /plan-exec.
func (c *Controller) detectProjectModules() []string {
root := c.sessionDir
for i := 0; i < 3 && root != ""; i++ {
if hasFile(root, "go.mod") || hasFile(root, "package.json") || hasFile(root, ".git") {
return listSourceDirs(root, 2)
}
root = filepath.Dir(root)
if root == filepath.Dir(root) {
break
// Use workspace root if available (set via Options.WorkspaceRoot).
root := c.WorkspaceRoot()
if root == "" {
root = c.sessionDir
for i := 0; i < 3 && root != ""; i++ {
if hasFile(root, "go.mod") || hasFile(root, "package.json") || hasFile(root, ".git") {
break
}
root = filepath.Dir(root)
if root == filepath.Dir(root) {
return nil
}
}
}
return nil
return listSourceDirs(root, 2)
}

func hasFile(dir, name string) bool {
Expand Down
7 changes: 5 additions & 2 deletions internal/control/input.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,9 @@ func IsSyntheticUserMessage(content string) bool {
}

// syntheticPrefixes must be kept in sync with the synthetic user messages
// injected by the controller (planApprovedMessage), agent loop
// (streamRecoveryMessage, finalReadinessRetryMessage, emptyFinalRetryMessage,
// injected by the controller (planApprovedMessage, goalContinueTurn,
// goalSelfCheckTurn), agent loop (streamRecoveryMessage,
// finalReadinessRetryMessage, emptyFinalRetryMessage,
// executorHandoffRetryMessage in internal/agent/agent.go), and compaction
// folds (internal/agent/compact.go), which store summaries as user-role
// messages the chat UI must never render as user bubbles (#3653).
Expand All @@ -83,6 +84,8 @@ var syntheticPrefixes = []string{
"<compaction-summary>",
"Summary of the later conversation (compacted from here on):",
"Summary of earlier conversation (compacted up to here):",
"Continue pursuing the active goal.",
"The agent signaled goal completion",
}

// Compose applies the plan-mode marker to a turn's text when plan mode is on,
Expand Down