| author | |
| committer | |
| log | fe69e9694252e56eec676c1858dbfeadccaab8da |
| tree | f104e6a40123b7bc530f40c195c85c4aac490d75 |
| parent | e9d40e448186a13dab4bcd9875e287689400ec14 |
Co-authored-by: codex <codex@users.noreply.github.com>12 files changed, 104 insertions(+), 20 deletions(-)
apps/web/src/components/BranchToolbar.tsx+3-1| ... | ... | @@ -23,6 +23,7 @@ import { |
| 23 | 23 | import { ChevronDownIcon } from "lucide-react"; |
| 24 | 24 | |
| 25 | 25 | interface BranchToolbarProps { |
| 26 | threadId: string; | |
| 26 | 27 | envMode: "local" | "worktree"; |
| 27 | 28 | onEnvModeChange: (mode: "local" | "worktree") => void; |
| 28 | 29 | envLocked: boolean; |
| ... | ... | @@ -30,6 +31,7 @@ interface BranchToolbarProps { |
| 30 | 31 | } |
| 31 | 32 | |
| 32 | 33 | export default function BranchToolbar({ |
| 34 | threadId, | |
| 33 | 35 | envMode, |
| 34 | 36 | onEnvModeChange, |
| 35 | 37 | envLocked, |
| ... | ... | @@ -42,7 +44,7 @@ export default function BranchToolbar({ |
| 42 | 44 | const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false); |
| 43 | 45 | const [branchQuery, setBranchQuery] = useState(""); |
| 44 | 46 | |
| 45 | const activeThread = state.threads.find((thread) => thread.id === state.activeThreadId); | |
| 47 | const activeThread = state.threads.find((thread) => thread.id === threadId); | |
| 46 | 48 | const activeProject = state.projects.find((project) => project.id === activeThread?.projectId); |
| 47 | 49 | const activeThreadId = activeThread?.id; |
| 48 | 50 | const activeThreadBranch = activeThread?.branch ?? null; |
apps/web/src/components/ChatView.tsx+18-4| ... | ... | @@ -63,6 +63,7 @@ import { |
| 63 | 63 | } from "../session-logic"; |
| 64 | 64 | import { isScrollContainerNearBottom } from "../chat-scroll"; |
| 65 | 65 | import { useStore } from "../store"; |
| 66 | import { truncateTitle } from "../truncateTitle"; | |
| 66 | 67 | import { |
| 67 | 68 | DEFAULT_THREAD_TERMINAL_ID, |
| 68 | 69 | MAX_THREAD_TERMINAL_COUNT, |
| ... | ... | @@ -1129,6 +1130,9 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1129 | 1130 | revokePreviewUrls(existing); |
| 1130 | 1131 | return []; |
| 1131 | 1132 | }); |
| 1133 | setPrompt(""); | |
| 1134 | promptRef.current = ""; | |
| 1135 | setIsSending(false); | |
| 1132 | 1136 | setComposerCursor(0); |
| 1133 | 1137 | setComposerHighlightedItemId(null); |
| 1134 | 1138 | dragDepthRef.current = 0; |
| ... | ... | @@ -1650,7 +1654,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1650 | 1654 | (composerImagesSnapshot.length > 0 |
| 1651 | 1655 | ? `Image: ${composerImagesSnapshot[0]?.name ?? "attachment"}` |
| 1652 | 1656 | : "New thread"); |
| 1653 | const title = titleSeed.length > 50 ? `${titleSeed.slice(0, 50)}...` : titleSeed; | |
| 1657 | const title = truncateTitle(titleSeed); | |
| 1654 | 1658 | dispatch({ |
| 1655 | 1659 | type: "SET_THREAD_TITLE", |
| 1656 | 1660 | threadId: activeThread.id, |
| ... | ... | @@ -1944,6 +1948,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1944 | 1948 | className={`flex items-center justify-between border-b border-border px-5 ${isElectron ? "drag-region h-[52px]" : "py-3"}`} |
| 1945 | 1949 | > |
| 1946 | 1950 | <ChatHeader |
| 1951 | activeThreadId={activeThread.id} | |
| 1947 | 1952 | activeThreadTitle={activeThread.title} |
| 1948 | 1953 | activeProjectName={activeProject?.name} |
| 1949 | 1954 | activeProjectScripts={activeProject?.scripts} |
| ... | ... | @@ -2210,6 +2215,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2210 | 2215 | |
| 2211 | 2216 | {isGitRepo && ( |
| 2212 | 2217 | <BranchToolbar |
| 2218 | threadId={activeThread.id} | |
| 2213 | 2219 | envMode={envMode} |
| 2214 | 2220 | onEnvModeChange={onEnvModeChange} |
| 2215 | 2221 | envLocked={envLocked} |
| ... | ... | @@ -2286,6 +2292,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2286 | 2292 | } |
| 2287 | 2293 | |
| 2288 | 2294 | interface ChatHeaderProps { |
| 2295 | activeThreadId: string; | |
| 2289 | 2296 | activeThreadTitle: string; |
| 2290 | 2297 | activeProjectName: string | undefined; |
| 2291 | 2298 | activeProjectScripts: ProjectScript[] | undefined; |
| ... | ... | @@ -2301,6 +2308,7 @@ interface ChatHeaderProps { |
| 2301 | 2308 | } |
| 2302 | 2309 | |
| 2303 | 2310 | const ChatHeader = memo(function ChatHeader({ |
| 2311 | activeThreadId, | |
| 2304 | 2312 | activeThreadTitle, |
| 2305 | 2313 | activeProjectName, |
| 2306 | 2314 | activeProjectScripts, |
| ... | ... | @@ -2333,8 +2341,12 @@ const ChatHeader = memo(function ChatHeader({ |
| 2333 | 2341 | onUpdateScript={onUpdateProjectScript} |
| 2334 | 2342 | /> |
| 2335 | 2343 | )} |
| 2336 | {activeProjectName && <OpenInPicker keybindings={keybindings} />} | |
| 2337 | {activeProjectName && <GitActionsControl api={api} gitCwd={gitCwd} />} | |
| 2344 | {activeProjectName && ( | |
| 2345 | <OpenInPicker keybindings={keybindings} activeThreadId={activeThreadId} /> | |
| 2346 | )} | |
| 2347 | {activeProjectName && ( | |
| 2348 | <GitActionsControl api={api} gitCwd={gitCwd} activeThreadId={activeThreadId} /> | |
| 2349 | )} | |
| 2338 | 2350 | <Toggle |
| 2339 | 2351 | pressed={diffOpen} |
| 2340 | 2352 | onPressedChange={onToggleDiff} |
| ... | ... | @@ -2827,8 +2839,10 @@ const ReasoningEffortPicker = memo(function ReasoningEffortPicker(props: { |
| 2827 | 2839 | |
| 2828 | 2840 | const OpenInPicker = memo(function OpenInPicker({ |
| 2829 | 2841 | keybindings, |
| 2842 | activeThreadId, | |
| 2830 | 2843 | }: { |
| 2831 | 2844 | keybindings: ResolvedKeybindingsConfig; |
| 2845 | activeThreadId: string | null; | |
| 2832 | 2846 | }) { |
| 2833 | 2847 | const [lastEditor, setLastEditor] = useState<EditorId>(() => { |
| 2834 | 2848 | const stored = localStorage.getItem(LAST_EDITOR_KEY); |
| ... | ... | @@ -2855,7 +2869,7 @@ const OpenInPicker = memo(function OpenInPicker({ |
| 2855 | 2869 | |
| 2856 | 2870 | const api = useNativeApi(); |
| 2857 | 2871 | const { state } = useStore(); |
| 2858 | const activeThread = state.threads.find((t) => t.id === state.activeThreadId); | |
| 2872 | const activeThread = state.threads.find((t) => t.id === activeThreadId); | |
| 2859 | 2873 | const activeProject = state.projects.find((p) => p.id === activeThread?.projectId); |
| 2860 | 2874 | |
| 2861 | 2875 | const openInEditor = useCallback( |
apps/web/src/components/GitActionsControl.tsx+6-4| ... | ... | @@ -38,12 +38,12 @@ import { |
| 38 | 38 | gitStatusQueryOptions, |
| 39 | 39 | invalidateGitQueries, |
| 40 | 40 | } from "~/lib/gitReactQuery"; |
| 41 | import { useStore } from "~/store"; | |
| 42 | 41 | import { preferredTerminalEditor, resolvePathLinkTarget } from "~/terminal-links"; |
| 43 | 42 | |
| 44 | 43 | interface GitActionsControlProps { |
| 45 | 44 | api: NativeApi | undefined; |
| 46 | 45 | gitCwd: string | null; |
| 46 | activeThreadId: string | null; | |
| 47 | 47 | } |
| 48 | 48 | |
| 49 | 49 | function getMenuActionDisabledReason( |
| ... | ... | @@ -136,9 +136,11 @@ function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) { |
| 136 | 136 | return <InfoIcon className={iconClassName} />; |
| 137 | 137 | } |
| 138 | 138 | |
| 139 | export default function GitActionsControl({ api, gitCwd }: GitActionsControlProps) { | |
| 140 | const { state } = useStore(); | |
| 141 | const activeThreadId = state.activeThreadId; | |
| 139 | export default function GitActionsControl({ | |
| 140 | api, | |
| 141 | gitCwd, | |
| 142 | activeThreadId, | |
| 143 | }: GitActionsControlProps) { | |
| 142 | 144 | const threadToastData = useMemo( |
| 143 | 145 | () => (activeThreadId ? { threadId: activeThreadId } : undefined), |
| 144 | 146 | [activeThreadId], |
apps/web/src/components/Sidebar.tsx+4-2| ... | ... | @@ -397,7 +397,9 @@ export default function Sidebar() { |
| 397 | 397 | |
| 398 | 398 | useEffect(() => { |
| 399 | 399 | const onWindowKeyDown = (event: KeyboardEvent) => { |
| 400 | const activeThread = state.threads.find((t) => t.id === state.activeThreadId); | |
| 400 | const activeThread = routeThreadId | |
| 401 | ? state.threads.find((thread) => thread.id === routeThreadId) | |
| 402 | : undefined; | |
| 401 | 403 | if (isChatNewLocalShortcut(event, keybindings)) { |
| 402 | 404 | const projectId = activeThread?.projectId ?? state.projects[0]?.id; |
| 403 | 405 | if (!projectId) return; |
| ... | ... | @@ -420,7 +422,7 @@ export default function Sidebar() { |
| 420 | 422 | return () => { |
| 421 | 423 | window.removeEventListener("keydown", onWindowKeyDown); |
| 422 | 424 | }; |
| 423 | }, [handleNewThread, keybindings, state.activeThreadId, state.projects, state.threads]); | |
| 425 | }, [handleNewThread, keybindings, routeThreadId, state.projects, state.threads]); | |
| 424 | 426 | |
| 425 | 427 | return ( |
| 426 | 428 | <aside className="sidebar flex h-full w-[260px] shrink-0 flex-col border-r border-border bg-card"> |
apps/web/src/router.tsx+9-2| ... | ... | @@ -1,8 +1,15 @@ |
| 1 | import { createRouter } from "@tanstack/react-router"; | |
| 1 | import { createHashHistory, createRouter } from "@tanstack/react-router"; | |
| 2 | 2 | |
| 3 | import { isElectron } from "./env"; | |
| 3 | 4 | import { routeTree } from "./routeTree.gen"; |
| 4 | 5 | |
| 5 | export const router = createRouter({ routeTree }); | |
| 6 | const useHashHistory = | |
| 7 | isElectron && typeof window !== "undefined" && window.location.protocol === "file:"; | |
| 8 | ||
| 9 | export const router = createRouter({ | |
| 10 | routeTree, | |
| 11 | ...(useHashHistory ? { history: createHashHistory() } : {}), | |
| 12 | }); | |
| 6 | 13 | |
| 7 | 14 | declare module "@tanstack/react-router" { |
| 8 | 15 | interface Register { |
apps/web/src/routes/__root.tsx+5| ... | ... | @@ -67,6 +67,7 @@ function AutoProjectBootstrap() { |
| 67 | 67 | threadId: existingThread.id, |
| 68 | 68 | }); |
| 69 | 69 | } |
| 70 | dispatch({ type: "SET_THREADS_HYDRATED", hydrated: true }); | |
| 70 | 71 | return; |
| 71 | 72 | } |
| 72 | 73 | |
| ... | ... | @@ -82,12 +83,14 @@ function AutoProjectBootstrap() { |
| 82 | 83 | cwd: payload.cwd, |
| 83 | 84 | model: DEFAULT_MODEL, |
| 84 | 85 | expanded: true, |
| 86 | scripts: [], | |
| 85 | 87 | }, |
| 86 | 88 | }); |
| 87 | 89 | dispatch({ |
| 88 | 90 | type: "ADD_THREAD", |
| 89 | 91 | thread: createThread(projectId), |
| 90 | 92 | }); |
| 93 | dispatch({ type: "SET_THREADS_HYDRATED", hydrated: true }); | |
| 91 | 94 | }); |
| 92 | 95 | }, [state.projects, state.threads, state.activeThreadId, dispatch]); |
| 93 | 96 | |
| ... | ... | @@ -118,8 +121,10 @@ function DesktopProjectBootstrap() { |
| 118 | 121 | cwd: project.cwd, |
| 119 | 122 | model: DEFAULT_MODEL, |
| 120 | 123 | expanded: true, |
| 124 | scripts: project.scripts, | |
| 121 | 125 | })), |
| 122 | 126 | }); |
| 127 | dispatch({ type: "SET_THREADS_HYDRATED", hydrated: true }); | |
| 123 | 128 | bootstrappedRef.current = true; |
| 124 | 129 | } catch { |
| 125 | 130 | if (disposed) return; |
apps/web/src/routes/_chat.$threadId.tsx+6-2| ... | ... | @@ -16,6 +16,10 @@ function ChatThreadRouteView() { |
| 16 | 16 | return; |
| 17 | 17 | } |
| 18 | 18 | |
| 19 | if (!state.threadsHydrated) { | |
| 20 | return; | |
| 21 | } | |
| 22 | ||
| 19 | 23 | if (!threadExists) { |
| 20 | 24 | void navigate({ to: "/", replace: true }); |
| 21 | 25 | return; |
| ... | ... | @@ -26,9 +30,9 @@ function ChatThreadRouteView() { |
| 26 | 30 | } |
| 27 | 31 | |
| 28 | 32 | dispatch({ type: "SET_ACTIVE_THREAD", threadId }); |
| 29 | }, [dispatch, navigate, state.activeThreadId, threadExists, threadId]); | |
| 33 | }, [dispatch, navigate, state.activeThreadId, state.threadsHydrated, threadExists, threadId]); | |
| 30 | 34 | |
| 31 | if (!threadId || !threadExists) { | |
| 35 | if (!threadId || !state.threadsHydrated || !threadExists) { | |
| 32 | 36 | return null; |
| 33 | 37 | } |
| 34 | 38 |
apps/web/src/routes/_chat.index.tsx+2-5| ... | ... | @@ -4,6 +4,7 @@ import { type FormEvent, useMemo, useState } from "react"; |
| 4 | 4 | import { Button } from "../components/ui/button"; |
| 5 | 5 | import { Textarea } from "../components/ui/textarea"; |
| 6 | 6 | import { createThread } from "../threadFactory"; |
| 7 | import { truncateTitle } from "../truncateTitle"; | |
| 7 | 8 | import { useStore } from "../store"; |
| 8 | 9 | |
| 9 | 10 | function ChatIndexRouteView() { |
| ... | ... | @@ -24,11 +25,7 @@ function ChatIndexRouteView() { |
| 24 | 25 | event.preventDefault(); |
| 25 | 26 | if (!defaultProject) return; |
| 26 | 27 | |
| 27 | const titleSeed = draft.trim(); | |
| 28 | const threadTitle = | |
| 29 | titleSeed.length > 0 | |
| 30 | ? (titleSeed.length > 50 ? `${titleSeed.slice(0, 50)}...` : titleSeed) | |
| 31 | : undefined; | |
| 28 | const threadTitle = truncateTitle(draft); | |
| 32 | 29 | const thread = createThread( |
| 33 | 30 | defaultProject.id, |
| 34 | 31 | threadTitle |
apps/web/src/store.test.ts+14| ... | ... | @@ -80,6 +80,7 @@ function makeState(thread: Thread): AppState { |
| 80 | 80 | ], |
| 81 | 81 | threads: [thread], |
| 82 | 82 | activeThreadId: thread.id, |
| 83 | threadsHydrated: true, | |
| 83 | 84 | runtimeMode: "full-access", |
| 84 | 85 | diffOpen: false, |
| 85 | 86 | diffThreadId: null, |
| ... | ... | @@ -617,6 +618,7 @@ describe("store reducer thread continuity", () => { |
| 617 | 618 | }), |
| 618 | 619 | ], |
| 619 | 620 | activeThreadId: "thread-b", |
| 621 | threadsHydrated: true, | |
| 620 | 622 | runtimeMode: "full-access", |
| 621 | 623 | diffOpen: true, |
| 622 | 624 | diffThreadId: "thread-b", |
| ... | ... | @@ -674,6 +676,7 @@ describe("store reducer thread continuity", () => { |
| 674 | 676 | ], |
| 675 | 677 | threads: [makeThread({ id: "thread-a", projectId: "project-old-a" })], |
| 676 | 678 | activeThreadId: "thread-a", |
| 679 | threadsHydrated: true, | |
| 677 | 680 | runtimeMode: "full-access", |
| 678 | 681 | diffOpen: false, |
| 679 | 682 | diffThreadId: null, |
| ... | ... | @@ -725,6 +728,16 @@ describe("store reducer thread continuity", () => { |
| 725 | 728 | ]); |
| 726 | 729 | }); |
| 727 | 730 | |
| 731 | it("marks threads hydration state", () => { | |
| 732 | const state = makeState(makeThread()); | |
| 733 | const next = reducer(state, { | |
| 734 | type: "SET_THREADS_HYDRATED", | |
| 735 | hydrated: false, | |
| 736 | }); | |
| 737 | ||
| 738 | expect(next.threadsHydrated).toBe(false); | |
| 739 | }); | |
| 740 | ||
| 728 | 741 | it("deletes a project and all of its threads", () => { |
| 729 | 742 | const state: AppState = { |
| 730 | 743 | projects: [ |
| ... | ... | @@ -756,6 +769,7 @@ describe("store reducer thread continuity", () => { |
| 756 | 769 | }), |
| 757 | 770 | ], |
| 758 | 771 | activeThreadId: "thread-a", |
| 772 | threadsHydrated: true, | |
| 759 | 773 | runtimeMode: "full-access", |
| 760 | 774 | diffOpen: true, |
| 761 | 775 | diffThreadId: "thread-a", |
apps/web/src/store.ts+13| ... | ... | @@ -38,6 +38,7 @@ type Action = |
| 38 | 38 | | { type: "ADD_PROJECT"; project: Project } |
| 39 | 39 | | { type: "SET_PROJECT_SCRIPTS"; projectId: string; scripts: ProjectScript[] } |
| 40 | 40 | | { type: "SYNC_PROJECTS"; projects: Project[] } |
| 41 | | { type: "SET_THREADS_HYDRATED"; hydrated: boolean } | |
| 41 | 42 | | { type: "TOGGLE_PROJECT"; projectId: string } |
| 42 | 43 | | { type: "DELETE_PROJECT"; projectId: string } |
| 43 | 44 | | { type: "ADD_THREAD"; thread: Thread } |
| ... | ... | @@ -113,6 +114,7 @@ export interface AppState { |
| 113 | 114 | projects: Project[]; |
| 114 | 115 | threads: Thread[]; |
| 115 | 116 | activeThreadId: string | null; |
| 117 | threadsHydrated: boolean; | |
| 116 | 118 | runtimeMode: RuntimeMode; |
| 117 | 119 | diffOpen: boolean; |
| 118 | 120 | diffThreadId: string | null; |
| ... | ... | @@ -136,6 +138,7 @@ const initialState: AppState = { |
| 136 | 138 | projects: [], |
| 137 | 139 | threads: [], |
| 138 | 140 | activeThreadId: null, |
| 141 | threadsHydrated: false, | |
| 139 | 142 | runtimeMode: DEFAULT_RUNTIME_MODE, |
| 140 | 143 | diffOpen: false, |
| 141 | 144 | diffThreadId: null, |
| ... | ... | @@ -167,6 +170,7 @@ function readPersistedState(): AppState { |
| 167 | 170 | ...hydrated, |
| 168 | 171 | threads, |
| 169 | 172 | activeThreadId, |
| 173 | threadsHydrated: threads.length > 0, | |
| 170 | 174 | diffOpen: false, |
| 171 | 175 | diffThreadId: null, |
| 172 | 176 | diffTurnId: null, |
| ... | ... | @@ -593,6 +597,15 @@ export function reducer(state: AppState, action: Action): AppState { |
| 593 | 597 | ), |
| 594 | 598 | }; |
| 595 | 599 | |
| 600 | case "SET_THREADS_HYDRATED": | |
| 601 | if (state.threadsHydrated === action.hydrated) { | |
| 602 | return state; | |
| 603 | } | |
| 604 | return { | |
| 605 | ...state, | |
| 606 | threadsHydrated: action.hydrated, | |
| 607 | }; | |
| 608 | ||
| 596 | 609 | case "SYNC_PROJECTS": { |
| 597 | 610 | const previousByCwd = new Map( |
| 598 | 611 | state.projects.map((project) => [project.cwd, project] as const), |
apps/web/src/truncateTitle.test.ts created+17| ... | ... | @@ -0,0 +1,17 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 2 | ||
| 3 | import { truncateTitle } from "./truncateTitle"; | |
| 4 | ||
| 5 | describe("truncateTitle", () => { | |
| 6 | it("trims surrounding whitespace", () => { | |
| 7 | expect(truncateTitle(" hello world ")).toBe("hello world"); | |
| 8 | }); | |
| 9 | ||
| 10 | it("returns trimmed text when within max length", () => { | |
| 11 | expect(truncateTitle("alpha", 10)).toBe("alpha"); | |
| 12 | }); | |
| 13 | ||
| 14 | it("appends ellipsis when text exceeds max length", () => { | |
| 15 | expect(truncateTitle("abcdefghij", 5)).toBe("abcde..."); | |
| 16 | }); | |
| 17 | }); |
apps/web/src/truncateTitle.ts created+7| ... | ... | @@ -0,0 +1,7 @@ |
| 1 | export function truncateTitle(text: string, maxLength = 50): string { | |
| 2 | const trimmed = text.trim(); | |
| 3 | if (trimmed.length <= maxLength) { | |
| 4 | return trimmed; | |
| 5 | } | |
| 6 | return `${trimmed.slice(0, maxLength)}...`; | |
| 7 | } |