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