From b59fc9b17678b5ccb6c7f0619542ed4a500e995e Mon Sep 17 00:00:00 2001 From: Vladislav Forsh Date: Fri, 27 Feb 2026 21:38:20 +0300 Subject: [PATCH 1/9] feat(threads): add thread info modal with rename and context usage --- src/App.tsx | 73 +++- src/features/app/components/MainHeader.tsx | 38 ++ .../hooks/layoutNodes/buildPrimaryNodes.tsx | 3 + .../layout/hooks/layoutNodes/types.ts | 4 + .../threads/components/ThreadInfoPrompt.tsx | 324 ++++++++++++++++++ src/styles/worktree-modal.css | 134 ++++++++ 6 files changed, 575 insertions(+), 1 deletion(-) create mode 100644 src/features/threads/components/ThreadInfoPrompt.tsx diff --git a/src/App.tsx b/src/App.tsx index 1a0694b5d..955d9b4ed 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -151,12 +151,24 @@ import { } from "@app/orchestration/useWorkspaceOrchestration"; import { useAppShellOrchestration } from "@app/orchestration/useLayoutOrchestration"; import { buildCodexArgsOptions } from "@threads/utils/codexArgsProfiles"; +import { clampThreadName } from "@threads/utils/threadNaming"; import { normalizeCodexArgsInput } from "@/utils/codexArgsInput"; import { resolveWorkspaceRuntimeCodexArgsBadgeLabel, resolveWorkspaceRuntimeCodexArgsOverride, } from "@threads/utils/threadCodexParamsSeed"; -import { setWorkspaceRuntimeCodexArgs } from "@services/tauri"; +import { generateRunMetadata, setWorkspaceRuntimeCodexArgs } from "@services/tauri"; + +const MAX_THREAD_TITLE_PROMPT_CHARS = 1200; + +function cleanThreadTitlePrompt(text: string) { + const withoutImages = text.replace(/\[image(?: x\d+)?\]/gi, " "); + const withoutSkills = withoutImages.replace(/(^|\s)\$[A-Za-z0-9_-]+(?=\s|$)/g, " "); + const normalized = withoutSkills.replace(/\s+/g, " ").trim(); + return normalized.length > MAX_THREAD_TITLE_PROMPT_CHARS + ? normalized.slice(0, MAX_THREAD_TITLE_PROMPT_CHARS) + : normalized; +} const AboutView = lazy(() => import("@/features/about/components/AboutView").then((module) => ({ @@ -1545,6 +1557,62 @@ function MainApp() { recentThreadsUpdatedAt: updatedAt > 0 ? updatedAt : null, }; }, [activeWorkspaceId, threadsByWorkspace]); + const activeThreadSummary = useMemo(() => { + if (!activeWorkspaceId || !activeThreadId) { + return null; + } + const threads = threadsByWorkspace[activeWorkspaceId] ?? []; + return threads.find((thread) => thread.id === activeThreadId) ?? null; + }, [activeThreadId, activeWorkspaceId, threadsByWorkspace]); + const activeThreadInfo = useMemo(() => { + if (!activeWorkspace || !activeThreadId) { + return null; + } + return { + threadId: activeThreadId, + name: activeThreadSummary?.name?.trim() || "Untitled thread", + workspaceName: activeWorkspace.name, + branchName: gitStatus.branchName || "unknown", + createdAt: activeThreadSummary?.createdAt ?? null, + updatedAt: activeThreadSummary?.updatedAt ?? null, + modelId: activeThreadSummary?.modelId ?? null, + effort: activeThreadSummary?.effort ?? null, + tokenUsage: activeTokenUsage, + }; + }, [ + activeThreadId, + activeThreadSummary, + activeWorkspace, + activeTokenUsage, + gitStatus.branchName, + ]); + const handleHeaderRenameActiveThreadName = useCallback( + (name: string) => { + if (!activeWorkspaceId || !activeThreadId) { + return; + } + renameThread(activeWorkspaceId, activeThreadId, name); + }, + [activeThreadId, activeWorkspaceId, renameThread], + ); + const handleGenerateActiveThreadName = useCallback(async () => { + if (!activeWorkspaceId || !activeThreadId) { + return null; + } + const firstUserMessage = activeItems.find( + (item) => item.kind === "message" && item.role === "user", + ); + const rawPrompt = + firstUserMessage && firstUserMessage.kind === "message" + ? firstUserMessage.text + : ""; + const prompt = cleanThreadTitlePrompt(rawPrompt); + if (!prompt) { + return null; + } + const metadata = await generateRunMetadata(activeWorkspaceId, prompt); + return clampThreadName(metadata.title ?? ""); + }, [activeItems, activeThreadId, activeWorkspaceId]); const { content: agentMdContent, exists: agentMdExists, @@ -2193,6 +2261,9 @@ function MainApp() { handleCheckoutPullRequest(pullRequest.number), onCreateBranch: handleCreateBranch, onCopyThread: handleCopyThread, + activeThreadInfo, + onRenameActiveThreadName: handleHeaderRenameActiveThreadName, + onGenerateActiveThreadName: handleGenerateActiveThreadName, onToggleTerminal: handleToggleTerminalWithFocus, showTerminalButton: !isCompact, showWorkspaceTools: !isCompact, diff --git a/src/features/app/components/MainHeader.tsx b/src/features/app/components/MainHeader.tsx index 63cc7b714..a47e661eb 100644 --- a/src/features/app/components/MainHeader.tsx +++ b/src/features/app/components/MainHeader.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import Check from "lucide-react/dist/esm/icons/check"; import Copy from "lucide-react/dist/esm/icons/copy"; +import Info from "lucide-react/dist/esm/icons/info"; import Terminal from "lucide-react/dist/esm/icons/terminal"; import { revealItemInDir } from "@tauri-apps/plugin-opener"; import type { BranchInfo, OpenAppTarget, WorkspaceInfo } from "../../../types"; @@ -18,6 +19,10 @@ import { LaunchScriptButton } from "./LaunchScriptButton"; import { LaunchScriptEntryButton } from "./LaunchScriptEntryButton"; import type { WorkspaceLaunchScriptsState } from "../hooks/useWorkspaceLaunchScripts"; import { useMenuController } from "../hooks/useMenuController"; +import { + ThreadInfoPrompt, + type ThreadInfoPromptThread, +} from "../../threads/components/ThreadInfoPrompt"; type MainHeaderProps = { workspace: WorkspaceInfo; @@ -36,6 +41,9 @@ type MainHeaderProps = { onCreateBranch: (name: string) => Promise | void; canCopyThread?: boolean; onCopyThread?: () => void | Promise; + activeThreadInfo?: ThreadInfoPromptThread | null; + onRenameActiveThreadName?: (name: string) => Promise | void; + onGenerateActiveThreadName?: () => Promise; onToggleTerminal: () => void; isTerminalOpen: boolean; showTerminalButton?: boolean; @@ -89,6 +97,9 @@ export function MainHeader({ onCreateBranch, canCopyThread = false, onCopyThread, + activeThreadInfo = null, + onRenameActiveThreadName, + onGenerateActiveThreadName, onToggleTerminal, isTerminalOpen, showTerminalButton = true, @@ -110,6 +121,7 @@ export function MainHeader({ const [branchQuery, setBranchQuery] = useState(""); const [error, setError] = useState(null); const [copyFeedback, setCopyFeedback] = useState(false); + const [threadInfoOpen, setThreadInfoOpen] = useState(false); const copyTimeoutRef = useRef(null); const renameInputRef = useRef(null); const renameConfirmRef = useRef(null); @@ -551,6 +563,21 @@ export function MainHeader({ )} + + + +
+ Thread ID +
+ {thread.threadId} + +
+
+
+ Workspace + {thread.workspaceName} +
+
+ Branch + {thread.branchName} +
+
+ Created + {formatTimestamp(thread.createdAt)} +
+
+ Updated + {formatTimestamp(thread.updatedAt)} +
+ {thread.modelId ? ( +
+ Model + {thread.modelId} +
+ ) : null} + {thread.effort ? ( +
+ Effort + {thread.effort} +
+ ) : null} +
+ Context tokens + + {formatCompactTokens(contextUsedTokens)} + {" / "} + {contextWindowTokens && contextWindowTokens > 0 + ? formatCompactTokens(contextWindowTokens) + : "Unknown"} + +
+
+ Context left + + {contextRemainingPercent === null + ? "Unknown" + : `${contextRemainingPercent.toFixed(1)}%`} + +
+ +
+ +
+ + {renameModalOpen ? ( + { + if (isSaving || isGenerating) { + return; + } + setRenameModalOpen(false); + }} + ariaLabel="Rename thread title" + > +
Rename thread
+ +
+