authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-18 02:51:32-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-18 23:54:48-08:00
logfe69e9694252e56eec676c1858dbfeadccaab8da
treef104e6a40123b7bc530f40c195c85c4aac490d75
parente9d40e448186a13dab4bcd9875e287689400ec14

Address PR feedback on router and thread hydration

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 {
23import { ChevronDownIcon } from "lucide-react";23import { ChevronDownIcon } from "lucide-react";
2424
25interface BranchToolbarProps {25interface 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}
3132
32export default function BranchToolbar({33export 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("");
4446
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";
64import { isScrollContainerNearBottom } from "../chat-scroll";64import { isScrollContainerNearBottom } from "../chat-scroll";
65import { useStore } from "../store";65import { useStore } from "../store";
66import { truncateTitle } from "../truncateTitle";
66import {67import {
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 > 01654 (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 <ChatHeader1950 <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) {
22102215
2211 {isGitRepo && (2216 {isGitRepo && (
2212 <BranchToolbar2217 <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}
22872293
2288interface ChatHeaderProps {2294interface 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}
23022309
2303const ChatHeader = memo(function ChatHeader({2310const 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 <Toggle2350 <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: {
28272839
2828const OpenInPicker = memo(function OpenInPicker({2840const 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({
28552869
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);
28602874
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";
41import { useStore } from "~/store";
42import { preferredTerminalEditor, resolvePathLinkTarget } from "~/terminal-links";41import { preferredTerminalEditor, resolvePathLinkTarget } from "~/terminal-links";
4342
44interface GitActionsControlProps {43interface GitActionsControlProps {
45 api: NativeApi | undefined;44 api: NativeApi | undefined;
46 gitCwd: string | null;45 gitCwd: string | null;
46 activeThreadId: string | null;
47}47}
4848
49function getMenuActionDisabledReason(49function 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}
138138
139export default function GitActionsControl({ api, gitCwd }: GitActionsControlProps) {139export 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() {
397397
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]);
424426
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 @@
1import { createRouter } from "@tanstack/react-router";1import { createHashHistory, createRouter } from "@tanstack/react-router";
22
3import { isElectron } from "./env";
3import { routeTree } from "./routeTree.gen";4import { routeTree } from "./routeTree.gen";
45
5export const router = createRouter({ routeTree });6const useHashHistory =
7 isElectron && typeof window !== "undefined" && window.location.protocol === "file:";
8
9export const router = createRouter({
10 routeTree,
11 ...(useHashHistory ? { history: createHashHistory() } : {}),
12});
613
7declare module "@tanstack/react-router" {14declare 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 }
7273
...@@ -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]);
9396
...@@ -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 }
1818
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 }
2731
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]);
3034
31 if (!threadId || !threadExists) {35 if (!threadId || !state.threadsHydrated || !threadExists) {
32 return null;36 return null;
33 }37 }
3438
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";
4import { Button } from "../components/ui/button";4import { Button } from "../components/ui/button";
5import { Textarea } from "../components/ui/textarea";5import { Textarea } from "../components/ui/textarea";
6import { createThread } from "../threadFactory";6import { createThread } from "../threadFactory";
7import { truncateTitle } from "../truncateTitle";
7import { useStore } from "../store";8import { useStore } from "../store";
89
9function ChatIndexRouteView() {10function 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;
2627
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 threadTitle31 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 });
727730
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 };
595599
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 @@
1import { describe, expect, it } from "vitest";
2
3import { truncateTitle } from "./truncateTitle";
4
5describe("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 @@
1export 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}