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 {
2323import { ChevronDownIcon } from "lucide-react";
2424
2525interface BranchToolbarProps {
26 threadId: string;
2627 envMode: "local" | "worktree";
2728 onEnvModeChange: (mode: "local" | "worktree") => void;
2829 envLocked: boolean;
......@@ -30,6 +31,7 @@ interface BranchToolbarProps {
3031}
3132
3233export default function BranchToolbar({
34 threadId,
3335 envMode,
3436 onEnvModeChange,
3537 envLocked,
......@@ -42,7 +44,7 @@ export default function BranchToolbar({
4244 const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false);
4345 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);
4648 const activeProject = state.projects.find((project) => project.id === activeThread?.projectId);
4749 const activeThreadId = activeThread?.id;
4850 const activeThreadBranch = activeThread?.branch ?? null;
apps/web/src/components/ChatView.tsx+18-4
......@@ -63,6 +63,7 @@ import {
6363} from "../session-logic";
6464import { isScrollContainerNearBottom } from "../chat-scroll";
6565import { useStore } from "../store";
66import { truncateTitle } from "../truncateTitle";
6667import {
6768 DEFAULT_THREAD_TERMINAL_ID,
6869 MAX_THREAD_TERMINAL_COUNT,
......@@ -1129,6 +1130,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
11291130 revokePreviewUrls(existing);
11301131 return [];
11311132 });
1133 setPrompt("");
1134 promptRef.current = "";
1135 setIsSending(false);
11321136 setComposerCursor(0);
11331137 setComposerHighlightedItemId(null);
11341138 dragDepthRef.current = 0;
......@@ -1650,7 +1654,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
16501654 (composerImagesSnapshot.length > 0
16511655 ? `Image: ${composerImagesSnapshot[0]?.name ?? "attachment"}`
16521656 : "New thread");
1653 const title = titleSeed.length > 50 ? `${titleSeed.slice(0, 50)}...` : titleSeed;
1657 const title = truncateTitle(titleSeed);
16541658 dispatch({
16551659 type: "SET_THREAD_TITLE",
16561660 threadId: activeThread.id,
......@@ -1944,6 +1948,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
19441948 className={`flex items-center justify-between border-b border-border px-5 ${isElectron ? "drag-region h-[52px]" : "py-3"}`}
19451949 >
19461950 <ChatHeader
1951 activeThreadId={activeThread.id}
19471952 activeThreadTitle={activeThread.title}
19481953 activeProjectName={activeProject?.name}
19491954 activeProjectScripts={activeProject?.scripts}
......@@ -2210,6 +2215,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
22102215
22112216 {isGitRepo && (
22122217 <BranchToolbar
2218 threadId={activeThread.id}
22132219 envMode={envMode}
22142220 onEnvModeChange={onEnvModeChange}
22152221 envLocked={envLocked}
......@@ -2286,6 +2292,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
22862292}
22872293
22882294interface ChatHeaderProps {
2295 activeThreadId: string;
22892296 activeThreadTitle: string;
22902297 activeProjectName: string | undefined;
22912298 activeProjectScripts: ProjectScript[] | undefined;
......@@ -2301,6 +2308,7 @@ interface ChatHeaderProps {
23012308}
23022309
23032310const ChatHeader = memo(function ChatHeader({
2311 activeThreadId,
23042312 activeThreadTitle,
23052313 activeProjectName,
23062314 activeProjectScripts,
......@@ -2333,8 +2341,12 @@ const ChatHeader = memo(function ChatHeader({
23332341 onUpdateScript={onUpdateProjectScript}
23342342 />
23352343 )}
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 )}
23382350 <Toggle
23392351 pressed={diffOpen}
23402352 onPressedChange={onToggleDiff}
......@@ -2827,8 +2839,10 @@ const ReasoningEffortPicker = memo(function ReasoningEffortPicker(props: {
28272839
28282840const OpenInPicker = memo(function OpenInPicker({
28292841 keybindings,
2842 activeThreadId,
28302843}: {
28312844 keybindings: ResolvedKeybindingsConfig;
2845 activeThreadId: string | null;
28322846}) {
28332847 const [lastEditor, setLastEditor] = useState<EditorId>(() => {
28342848 const stored = localStorage.getItem(LAST_EDITOR_KEY);
......@@ -2855,7 +2869,7 @@ const OpenInPicker = memo(function OpenInPicker({
28552869
28562870 const api = useNativeApi();
28572871 const { state } = useStore();
2858 const activeThread = state.threads.find((t) => t.id === state.activeThreadId);
2872 const activeThread = state.threads.find((t) => t.id === activeThreadId);
28592873 const activeProject = state.projects.find((p) => p.id === activeThread?.projectId);
28602874
28612875 const openInEditor = useCallback(
apps/web/src/components/GitActionsControl.tsx+6-4
......@@ -38,12 +38,12 @@ import {
3838 gitStatusQueryOptions,
3939 invalidateGitQueries,
4040} from "~/lib/gitReactQuery";
41import { useStore } from "~/store";
4241import { preferredTerminalEditor, resolvePathLinkTarget } from "~/terminal-links";
4342
4443interface GitActionsControlProps {
4544 api: NativeApi | undefined;
4645 gitCwd: string | null;
46 activeThreadId: string | null;
4747}
4848
4949function getMenuActionDisabledReason(
......@@ -136,9 +136,11 @@ function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) {
136136 return <InfoIcon className={iconClassName} />;
137137}
138138
139export default function GitActionsControl({ api, gitCwd }: GitActionsControlProps) {
140 const { state } = useStore();
141 const activeThreadId = state.activeThreadId;
139export default function GitActionsControl({
140 api,
141 gitCwd,
142 activeThreadId,
143}: GitActionsControlProps) {
142144 const threadToastData = useMemo(
143145 () => (activeThreadId ? { threadId: activeThreadId } : undefined),
144146 [activeThreadId],
apps/web/src/components/Sidebar.tsx+4-2
......@@ -397,7 +397,9 @@ export default function Sidebar() {
397397
398398 useEffect(() => {
399399 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;
401403 if (isChatNewLocalShortcut(event, keybindings)) {
402404 const projectId = activeThread?.projectId ?? state.projects[0]?.id;
403405 if (!projectId) return;
......@@ -420,7 +422,7 @@ export default function Sidebar() {
420422 return () => {
421423 window.removeEventListener("keydown", onWindowKeyDown);
422424 };
423 }, [handleNewThread, keybindings, state.activeThreadId, state.projects, state.threads]);
425 }, [handleNewThread, keybindings, routeThreadId, state.projects, state.threads]);
424426
425427 return (
426428 <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 @@
1import { createRouter } from "@tanstack/react-router";
1import { createHashHistory, createRouter } from "@tanstack/react-router";
22
3import { isElectron } from "./env";
34import { 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
714declare module "@tanstack/react-router" {
815 interface Register {
apps/web/src/routes/__root.tsx+5
......@@ -67,6 +67,7 @@ function AutoProjectBootstrap() {
6767 threadId: existingThread.id,
6868 });
6969 }
70 dispatch({ type: "SET_THREADS_HYDRATED", hydrated: true });
7071 return;
7172 }
7273
......@@ -82,12 +83,14 @@ function AutoProjectBootstrap() {
8283 cwd: payload.cwd,
8384 model: DEFAULT_MODEL,
8485 expanded: true,
86 scripts: [],
8587 },
8688 });
8789 dispatch({
8890 type: "ADD_THREAD",
8991 thread: createThread(projectId),
9092 });
93 dispatch({ type: "SET_THREADS_HYDRATED", hydrated: true });
9194 });
9295 }, [state.projects, state.threads, state.activeThreadId, dispatch]);
9396
......@@ -118,8 +121,10 @@ function DesktopProjectBootstrap() {
118121 cwd: project.cwd,
119122 model: DEFAULT_MODEL,
120123 expanded: true,
124 scripts: project.scripts,
121125 })),
122126 });
127 dispatch({ type: "SET_THREADS_HYDRATED", hydrated: true });
123128 bootstrappedRef.current = true;
124129 } catch {
125130 if (disposed) return;
apps/web/src/routes/_chat.$threadId.tsx+6-2
......@@ -16,6 +16,10 @@ function ChatThreadRouteView() {
1616 return;
1717 }
1818
19 if (!state.threadsHydrated) {
20 return;
21 }
22
1923 if (!threadExists) {
2024 void navigate({ to: "/", replace: true });
2125 return;
......@@ -26,9 +30,9 @@ function ChatThreadRouteView() {
2630 }
2731
2832 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) {
3236 return null;
3337 }
3438
apps/web/src/routes/_chat.index.tsx+2-5
......@@ -4,6 +4,7 @@ import { type FormEvent, useMemo, useState } from "react";
44import { Button } from "../components/ui/button";
55import { Textarea } from "../components/ui/textarea";
66import { createThread } from "../threadFactory";
7import { truncateTitle } from "../truncateTitle";
78import { useStore } from "../store";
89
910function ChatIndexRouteView() {
......@@ -24,11 +25,7 @@ function ChatIndexRouteView() {
2425 event.preventDefault();
2526 if (!defaultProject) return;
2627
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);
3229 const thread = createThread(
3330 defaultProject.id,
3431 threadTitle
apps/web/src/store.test.ts+14
......@@ -80,6 +80,7 @@ function makeState(thread: Thread): AppState {
8080 ],
8181 threads: [thread],
8282 activeThreadId: thread.id,
83 threadsHydrated: true,
8384 runtimeMode: "full-access",
8485 diffOpen: false,
8586 diffThreadId: null,
......@@ -617,6 +618,7 @@ describe("store reducer thread continuity", () => {
617618 }),
618619 ],
619620 activeThreadId: "thread-b",
621 threadsHydrated: true,
620622 runtimeMode: "full-access",
621623 diffOpen: true,
622624 diffThreadId: "thread-b",
......@@ -674,6 +676,7 @@ describe("store reducer thread continuity", () => {
674676 ],
675677 threads: [makeThread({ id: "thread-a", projectId: "project-old-a" })],
676678 activeThreadId: "thread-a",
679 threadsHydrated: true,
677680 runtimeMode: "full-access",
678681 diffOpen: false,
679682 diffThreadId: null,
......@@ -725,6 +728,16 @@ describe("store reducer thread continuity", () => {
725728 ]);
726729 });
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
728741 it("deletes a project and all of its threads", () => {
729742 const state: AppState = {
730743 projects: [
......@@ -756,6 +769,7 @@ describe("store reducer thread continuity", () => {
756769 }),
757770 ],
758771 activeThreadId: "thread-a",
772 threadsHydrated: true,
759773 runtimeMode: "full-access",
760774 diffOpen: true,
761775 diffThreadId: "thread-a",
apps/web/src/store.ts+13
......@@ -38,6 +38,7 @@ type Action =
3838 | { type: "ADD_PROJECT"; project: Project }
3939 | { type: "SET_PROJECT_SCRIPTS"; projectId: string; scripts: ProjectScript[] }
4040 | { type: "SYNC_PROJECTS"; projects: Project[] }
41 | { type: "SET_THREADS_HYDRATED"; hydrated: boolean }
4142 | { type: "TOGGLE_PROJECT"; projectId: string }
4243 | { type: "DELETE_PROJECT"; projectId: string }
4344 | { type: "ADD_THREAD"; thread: Thread }
......@@ -113,6 +114,7 @@ export interface AppState {
113114 projects: Project[];
114115 threads: Thread[];
115116 activeThreadId: string | null;
117 threadsHydrated: boolean;
116118 runtimeMode: RuntimeMode;
117119 diffOpen: boolean;
118120 diffThreadId: string | null;
......@@ -136,6 +138,7 @@ const initialState: AppState = {
136138 projects: [],
137139 threads: [],
138140 activeThreadId: null,
141 threadsHydrated: false,
139142 runtimeMode: DEFAULT_RUNTIME_MODE,
140143 diffOpen: false,
141144 diffThreadId: null,
......@@ -167,6 +170,7 @@ function readPersistedState(): AppState {
167170 ...hydrated,
168171 threads,
169172 activeThreadId,
173 threadsHydrated: threads.length > 0,
170174 diffOpen: false,
171175 diffThreadId: null,
172176 diffTurnId: null,
......@@ -593,6 +597,15 @@ export function reducer(state: AppState, action: Action): AppState {
593597 ),
594598 };
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
596609 case "SYNC_PROJECTS": {
597610 const previousByCwd = new Map(
598611 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}