History

Address PR feedback on router and thread hydration

Julius Marminge committed 9 months ago

Commit fe69e961 parent e9d40e412 files · +104 −20Browse files

Unified · Split

12 files
  1. apps/web/src/components/BranchToolbar.tsx +3 −1
  2. apps/web/src/components/ChatView.tsx +18 −4
  3. apps/web/src/components/GitActionsControl.tsx +6 −4
  4. apps/web/src/components/Sidebar.tsx +4 −2
  5. apps/web/src/router.tsx +9 −2
  6. apps/web/src/routes/__root.tsx +5 −0
  7. apps/web/src/routes/_chat.$threadId.tsx +6 −2
  8. apps/web/src/routes/_chat.index.tsx +2 −5
  9. apps/web/src/store.test.ts +14 −0
  10. apps/web/src/store.ts +13 −0
  11. apps/web/src/truncateTitle.test.ts +17 −0
  12. apps/web/src/truncateTitle.ts +7 −0
apps/web/src/components/BranchToolbar.tsx+3−1
@@ -23,6 +23,7 @@ import { · 22 unmodified lines
23import { ChevronDownIcon } from "lucide-react";23import { ChevronDownIcon } from "lucide-react";
24 24
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 { · 1 unmodified line
30}31}
31 32
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({ · 6 unmodified lines
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 { · 62 unmodified lines
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) { · 1060 unmodified lines
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) { · 515 unmodified lines
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) { · 287 unmodified lines
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) { · 260 unmodified lines
2210 2215
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) { · 70 unmodified lines
2286}2292}
2287 2293
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 { · 9 unmodified lines
2301}2308}
2302 2309
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({ · 26 unmodified lines
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: { · 486 unmodified lines
2827 2839
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({ · 20 unmodified lines
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 { · 37 unmodified lines
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";
43 42
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}
48 48
49function getMenuActionDisabledReason(49function getMenuActionDisabledReason(
@@ -136,9 +136,11 @@ function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) { · 86 unmodified lines
136 return <InfoIcon className={iconClassName} />;136 return <InfoIcon className={iconClassName} />;
137}137}
138 138
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() { · 396 unmodified lines
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() { · 16 unmodified lines
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 @@
1import { createRouter } from "@tanstack/react-router";1import { createHashHistory, createRouter } from "@tanstack/react-router";
2 2
3import { isElectron } from "./env";
3import { routeTree } from "./routeTree.gen";4import { routeTree } from "./routeTree.gen";
4 5
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});
6 13
7declare module "@tanstack/react-router" {14declare module "@tanstack/react-router" {
8 interface Register {15 interface Register {
apps/web/src/routes/__root.tsx+5−0
@@ -67,6 +67,7 @@ function AutoProjectBootstrap() { · 66 unmodified lines
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() { · 9 unmodified lines
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() { · 24 unmodified lines
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() { · 15 unmodified lines
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() { · 4 unmodified lines
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"; · 3 unmodified lines
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";
8 9
9function ChatIndexRouteView() {10function ChatIndexRouteView() {
@@ -24,11 +25,7 @@ function ChatIndexRouteView() { · 14 unmodified lines
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 threadTitle31 threadTitle
apps/web/src/store.test.ts+14−0
@@ -80,6 +80,7 @@ function makeState(thread: Thread): AppState { · 79 unmodified lines
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", () => { · 531 unmodified lines
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", () => { · 51 unmodified lines
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", () => { · 45 unmodified lines
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", () => { · 25 unmodified lines
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−0
@@ -38,6 +38,7 @@ type Action = · 37 unmodified lines
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 { · 69 unmodified lines
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 = { · 17 unmodified lines
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 { · 25 unmodified lines
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 { · 420 unmodified lines
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+17−0
@@ -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+7−0
@@ -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}

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t