Unified · Split
| @@ -1,12 +1,13 @@ | |||
| 1 | import { | 1 | import { |
| 2 | ChevronRightIcon, | 2 | ChevronRightIcon, |
| 3 | FolderIcon, | 3 | FolderIcon, |
| 4 | GripVerticalIcon, | ||
| 4 | GitPullRequestIcon, | 5 | GitPullRequestIcon, |
| 5 | RocketIcon, | 6 | RocketIcon, |
| 6 | SquarePenIcon, | 7 | SquarePenIcon, |
| 7 | TerminalIcon, | 8 | TerminalIcon, |
| 8 | } from "lucide-react"; | 9 | } from "lucide-react"; |
| 9 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | 10 | import { type PointerEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 10 | import { | 11 | import { |
| 11 | DEFAULT_MODEL, | 12 | DEFAULT_MODEL, |
| 12 | type DesktopUpdateState, | 13 | type DesktopUpdateState, |
| @@ -20,6 +21,7 @@ import { useNavigate, useParams } from "@tanstack/react-router"; · 7 unmodified lines | |||
| 20 | import { useAppSettings } from "../appSettings"; | 21 | import { useAppSettings } from "../appSettings"; |
| 21 | import { isElectron } from "../env"; | 22 | import { isElectron } from "../env"; |
| 22 | import { APP_STAGE_LABEL } from "../branding"; | 23 | import { APP_STAGE_LABEL } from "../branding"; |
| 24 | import { resolveServerHttpOrigin } from "../lib/serverOrigin"; | ||
| 23 | import { newCommandId, newProjectId, newThreadId } from "../lib/utils"; | 25 | import { newCommandId, newProjectId, newThreadId } from "../lib/utils"; |
| 24 | import { useStore } from "../store"; | 26 | import { useStore } from "../store"; |
| 25 | import { isChatNewLocalShortcut, isChatNewShortcut, shortcutLabelForCommand } from "../keybindings"; | 27 | import { isChatNewLocalShortcut, isChatNewShortcut, shortcutLabelForCommand } from "../keybindings"; |
| @@ -80,6 +82,11 @@ function formatRelativeTime(iso: string): string { · 54 unmodified lines | |||
| 80 | return `${Math.floor(hours / 24)}d ago`; | 82 | return `${Math.floor(hours / 24)}d ago`; |
| 81 | } | 83 | } |
| 82 | 84 | ||
| 85 | function resolveProjectDropPosition(element: HTMLElement, clientY: number) { | ||
| 86 | const { top, height } = element.getBoundingClientRect(); | ||
| 87 | return clientY < top + height / 2 ? "before" : "after"; | ||
| 88 | } | ||
| 89 | |||
| 83 | interface ThreadStatusPill { | 90 | interface ThreadStatusPill { |
| 84 | label: "Working" | "Connecting" | "Completed" | "Pending Approval"; | 91 | label: "Working" | "Connecting" | "Completed" | "Pending Approval"; |
| 85 | colorClass: string; | 92 | colorClass: string; |
| @@ -212,29 +219,7 @@ function T3Wordmark() { · 126 unmodified lines | |||
| 212 | ); | 219 | ); |
| 213 | } | 220 | } |
| 214 | 221 | ||
| 215 | /** | 222 | const serverHttpOrigin = resolveServerHttpOrigin(); |
| 216 | * Derives the server's HTTP origin (scheme + host + port) from the same | ||
| 217 | * sources WsTransport uses, converting ws(s) to http(s). | ||
| 218 | */ | ||
| 219 | function getServerHttpOrigin(): string { | ||
| 220 | const bridgeUrl = window.desktopBridge?.getWsUrl(); | ||
| 221 | const envUrl = import.meta.env.VITE_WS_URL as string | undefined; | ||
| 222 | const wsUrl = | ||
| 223 | bridgeUrl && bridgeUrl.length > 0 | ||
| 224 | ? bridgeUrl | ||
| 225 | : envUrl && envUrl.length > 0 | ||
| 226 | ? envUrl | ||
| 227 | : `ws://${window.location.hostname}:${window.location.port}`; | ||
| 228 | // Parse to extract just the origin, dropping path/query (e.g. ?token=…) | ||
| 229 | const httpUrl = wsUrl.replace(/^wss:/, "https:").replace(/^ws:/, "http:"); | ||
| 230 | try { | ||
| 231 | return new URL(httpUrl).origin; | ||
| 232 | } catch { | ||
| 233 | return httpUrl; | ||
| 234 | } | ||
| 235 | } | ||
| 236 | |||
| 237 | const serverHttpOrigin = getServerHttpOrigin(); | ||
| 238 | 223 | ||
| 239 | function ProjectFavicon({ cwd }: { cwd: string }) { | 224 | function ProjectFavicon({ cwd }: { cwd: string }) { |
| 240 | const [status, setStatus] = useState<"loading" | "loaded" | "error">("loading"); | 225 | const [status, setStatus] = useState<"loading" | "loaded" | "error">("loading"); |
| @@ -260,6 +245,7 @@ export default function Sidebar() { · 19 unmodified lines | |||
| 260 | const projects = useStore((store) => store.projects); | 245 | const projects = useStore((store) => store.projects); |
| 261 | const threads = useStore((store) => store.threads); | 246 | const threads = useStore((store) => store.threads); |
| 262 | const markThreadUnread = useStore((store) => store.markThreadUnread); | 247 | const markThreadUnread = useStore((store) => store.markThreadUnread); |
| 248 | const moveProject = useStore((store) => store.moveProject); | ||
| 263 | const toggleProject = useStore((store) => store.toggleProject); | 249 | const toggleProject = useStore((store) => store.toggleProject); |
| 264 | const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearThreadDraft); | 250 | const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearThreadDraft); |
| 265 | const getDraftThreadByProjectId = useComposerDraftStore( | 251 | const getDraftThreadByProjectId = useComposerDraftStore( |
| @@ -294,6 +280,11 @@ export default function Sidebar() { · 28 unmodified lines | |||
| 294 | const [isAddingProject, setIsAddingProject] = useState(false); | 280 | const [isAddingProject, setIsAddingProject] = useState(false); |
| 295 | const [renamingThreadId, setRenamingThreadId] = useState<ThreadId | null>(null); | 281 | const [renamingThreadId, setRenamingThreadId] = useState<ThreadId | null>(null); |
| 296 | const [renamingTitle, setRenamingTitle] = useState(""); | 282 | const [renamingTitle, setRenamingTitle] = useState(""); |
| 283 | const [draggedProjectId, setDraggedProjectId] = useState<ProjectId | null>(null); | ||
| 284 | const [projectDropTarget, setProjectDropTarget] = useState<{ | ||
| 285 | projectId: ProjectId; | ||
| 286 | position: "before" | "after"; | ||
| 287 | } | null>(null); | ||
| 297 | const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState< | 288 | const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState< |
| 298 | ReadonlySet<ProjectId> | 289 | ReadonlySet<ProjectId> |
| 299 | >(() => new Set()); | 290 | >(() => new Set()); |
| @@ -967,6 +958,65 @@ export default function Sidebar() { · 667 unmodified lines | |||
| 967 | }); | 958 | }); |
| 968 | }, []); | 959 | }, []); |
| 969 | 960 | ||
| 961 | useEffect(() => { | ||
| 962 | if (!draggedProjectId) return; | ||
| 963 | |||
| 964 | const previousUserSelect = document.body.style.userSelect; | ||
| 965 | const previousCursor = document.body.style.cursor; | ||
| 966 | document.body.style.userSelect = "none"; | ||
| 967 | document.body.style.cursor = "grabbing"; | ||
| 968 | |||
| 969 | const handlePointerRelease = () => { | ||
| 970 | setDraggedProjectId(null); | ||
| 971 | setProjectDropTarget(null); | ||
| 972 | }; | ||
| 973 | |||
| 974 | window.addEventListener("pointerup", handlePointerRelease); | ||
| 975 | window.addEventListener("pointercancel", handlePointerRelease); | ||
| 976 | return () => { | ||
| 977 | document.body.style.userSelect = previousUserSelect; | ||
| 978 | document.body.style.cursor = previousCursor; | ||
| 979 | window.removeEventListener("pointerup", handlePointerRelease); | ||
| 980 | window.removeEventListener("pointercancel", handlePointerRelease); | ||
| 981 | }; | ||
| 982 | }, [draggedProjectId]); | ||
| 983 | |||
| 984 | function resetProjectDragState() { | ||
| 985 | setDraggedProjectId(null); | ||
| 986 | setProjectDropTarget(null); | ||
| 987 | } | ||
| 988 | |||
| 989 | function handleProjectDragStart(event: PointerEvent<HTMLButtonElement>, projectId: ProjectId) { | ||
| 990 | if (event.button !== 0) return; | ||
| 991 | |||
| 992 | event.preventDefault(); | ||
| 993 | event.stopPropagation(); | ||
| 994 | setDraggedProjectId(projectId); | ||
| 995 | setProjectDropTarget(null); | ||
| 996 | } | ||
| 997 | |||
| 998 | function handleProjectDragOver(event: PointerEvent<HTMLElement>, projectId: ProjectId) { | ||
| 999 | if (!draggedProjectId) return; | ||
| 1000 | |||
| 1001 | const position = resolveProjectDropPosition(event.currentTarget, event.clientY); | ||
| 1002 | setProjectDropTarget((current) => { | ||
| 1003 | if (current?.projectId === projectId && current.position === position) { | ||
| 1004 | return current; | ||
| 1005 | } | ||
| 1006 | return { projectId, position }; | ||
| 1007 | }); | ||
| 1008 | } | ||
| 1009 | |||
| 1010 | function handleProjectDrop(event: PointerEvent<HTMLElement>, projectId: ProjectId) { | ||
| 1011 | if (!draggedProjectId) return; | ||
| 1012 | |||
| 1013 | event.preventDefault(); | ||
| 1014 | event.stopPropagation(); | ||
| 1015 | const position = resolveProjectDropPosition(event.currentTarget, event.clientY); | ||
| 1016 | moveProject(draggedProjectId, projectId, position); | ||
| 1017 | resetProjectDragState(); | ||
| 1018 | } | ||
| 1019 | |||
| 970 | const wordmark = ( | 1020 | const wordmark = ( |
| 971 | <div className="flex items-center gap-2"> | 1021 | <div className="flex items-center gap-2"> |
| 972 | <SidebarTrigger className="shrink-0 md:hidden" /> | 1022 | <SidebarTrigger className="shrink-0 md:hidden" /> |
| @@ -1032,6 +1082,12 @@ export default function Sidebar() { · 59 unmodified lines | |||
| 1032 | hasHiddenThreads && !isThreadListExpanded | 1082 | hasHiddenThreads && !isThreadListExpanded |
| 1033 | ? projectThreads.slice(0, THREAD_PREVIEW_LIMIT) | 1083 | ? projectThreads.slice(0, THREAD_PREVIEW_LIMIT) |
| 1034 | : projectThreads; | 1084 | : projectThreads; |
| 1085 | const dropIndicator = | ||
| 1086 | draggedProjectId !== null && | ||
| 1087 | draggedProjectId !== project.id && | ||
| 1088 | projectDropTarget?.projectId === project.id | ||
| 1089 | ? projectDropTarget.position | ||
| 1090 | : null; | ||
| 1035 | 1091 | ||
| 1036 | return ( | 1092 | return ( |
| 1037 | <Collapsible | 1093 | <Collapsible |
| @@ -1043,13 +1099,39 @@ export default function Sidebar() { · 5 unmodified lines | |||
| 1043 | toggleProject(project.id); | 1099 | toggleProject(project.id); |
| 1044 | }} | 1100 | }} |
| 1045 | > | 1101 | > |
| 1046 | <SidebarMenuItem> | 1102 | <SidebarMenuItem className={draggedProjectId === project.id ? "opacity-60" : ""}> |
| 1047 | <div className="group/project-header relative"> | 1103 | {dropIndicator === "before" && ( |
| 1104 | <div className="pointer-events-none absolute inset-x-2 top-0 h-0.5 rounded-full bg-primary" /> | ||
| 1105 | )} | ||
| 1106 | {dropIndicator === "after" && ( | ||
| 1107 | <div className="pointer-events-none absolute inset-x-2 bottom-0 h-0.5 rounded-full bg-primary" /> | ||
| 1108 | )} | ||
| 1109 | <div | ||
| 1110 | className="group/project-header relative" | ||
| 1111 | onPointerEnter={(event) => handleProjectDragOver(event, project.id)} | ||
| 1112 | onPointerMove={(event) => handleProjectDragOver(event, project.id)} | ||
| 1113 | onPointerUp={(event) => handleProjectDrop(event, project.id)} | ||
| 1114 | > | ||
| 1115 | <button | ||
| 1116 | type="button" | ||
| 1117 | aria-label={`Reorder ${project.name}`} | ||
| 1118 | aria-grabbed={draggedProjectId === project.id} | ||
| 1119 | className={`absolute top-1/2 left-1 z-10 inline-flex size-5 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground/45 transition-colors hover:bg-accent hover:text-foreground ${ | ||
| 1120 | draggedProjectId === project.id ? "cursor-grabbing" : "cursor-grab" | ||
| 1121 | }`} | ||
| 1122 | onClick={(event) => { | ||
| 1123 | event.preventDefault(); | ||
| 1124 | event.stopPropagation(); | ||
| 1125 | }} | ||
| 1126 | onPointerDown={(event) => handleProjectDragStart(event, project.id)} | ||
| 1127 | > | ||
| 1128 | <GripVerticalIcon className="size-3.5" /> | ||
| 1129 | </button> | ||
| 1048 | <CollapsibleTrigger | 1130 | <CollapsibleTrigger |
| 1049 | render={ | 1131 | render={ |
| 1050 | <SidebarMenuButton | 1132 | <SidebarMenuButton |
| 1051 | size="sm" | 1133 | size="sm" |
| 1052 | className="gap-2 px-2 py-1.5 text-left hover:bg-accent group-hover/project-header:bg-accent group-hover/project-header:text-sidebar-accent-foreground" | 1134 | className="gap-2 py-1.5 pr-2 pl-7 text-left hover:bg-accent group-hover/project-header:bg-accent group-hover/project-header:text-sidebar-accent-foreground" |
| 1053 | /> | 1135 | /> |
| 1054 | } | 1136 | } |
| 1055 | onContextMenu={(event) => { | 1137 | onContextMenu={(event) => { |
| @@ -0,0 +1,19 @@ | |||
| 1 | export function resolveServerHttpOrigin(): string { | ||
| 2 | if (typeof window === "undefined") return ""; | ||
| 3 | |||
| 4 | const bridgeWsUrl = window.desktopBridge?.getWsUrl?.(); | ||
| 5 | const envWsUrl = import.meta.env.VITE_WS_URL as string | undefined; | ||
| 6 | const wsUrl = | ||
| 7 | typeof bridgeWsUrl === "string" && bridgeWsUrl.length > 0 | ||
| 8 | ? bridgeWsUrl | ||
| 9 | : typeof envWsUrl === "string" && envWsUrl.length > 0 | ||
| 10 | ? envWsUrl | ||
| 11 | : `ws://${window.location.hostname}:${window.location.port}`; | ||
| 12 | const httpUrl = wsUrl.replace(/^wss:/, "https:").replace(/^ws:/, "http:"); | ||
| 13 | |||
| 14 | try { | ||
| 15 | return new URL(httpUrl).origin; | ||
| 16 | } catch { | ||
| 17 | return httpUrl; | ||
| 18 | } | ||
| 19 | } | ||
| @@ -1,7 +1,9 @@ | |||
| 1 | import type { OrchestrationReadModel } from "@t3tools/contracts"; | ||
| 1 | import { ProjectId, ThreadId, TurnId } from "@t3tools/contracts"; | 2 | import { ProjectId, ThreadId, TurnId } from "@t3tools/contracts"; |
| 2 | import { describe, expect, it } from "vitest"; | 3 | import { describe, expect, it } from "vitest"; |
| 3 | 4 | ||
| 4 | import { markThreadUnread, type AppState } from "./store"; | 5 | import { markThreadUnread, moveProject, syncServerReadModel, type AppState } from "./store"; |
| 6 | import type { Project } from "./types"; | ||
| 5 | import type { Thread } from "./types"; | 7 | import type { Thread } from "./types"; |
| 6 | 8 | ||
| 7 | function makeThread(overrides: Partial<Thread> = {}): Thread { | 9 | function makeThread(overrides: Partial<Thread> = {}): Thread { |
| @@ -24,18 +26,22 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { · 16 unmodified lines | |||
| 24 | }; | 26 | }; |
| 25 | } | 27 | } |
| 26 | 28 | ||
| 27 | function makeState(thread: Thread): AppState { | 29 | function makeProject(overrides: Partial<Project> = {}): Project { |
| 28 | return { | 30 | return { |
| 29 | projects: [ | 31 | id: ProjectId.makeUnsafe("project-1"), |
| 30 | { | 32 | name: "Project", |
| 31 | id: ProjectId.makeUnsafe("project-1"), | 33 | cwd: "/tmp/project", |
| 32 | name: "Project", | 34 | model: "gpt-5-codex", |
| 33 | cwd: "/tmp/project", | 35 | expanded: true, |
| 34 | model: "gpt-5-codex", | 36 | scripts: [], |
| 35 | expanded: true, | 37 | ...overrides, |
| 36 | scripts: [], | 38 | }; |
| 37 | }, | 39 | } |
| 38 | ], | 40 | |
| 41 | function makeState(thread: Thread, projects: Project[] = [makeProject()]): AppState { | ||
| 42 | return { | ||
| 43 | projects, | ||
| 44 | projectOrder: projects.map((project) => project.id), | ||
| 39 | threads: [thread], | 45 | threads: [thread], |
| 40 | threadsHydrated: true, | 46 | threadsHydrated: true, |
| 41 | runtimeMode: "full-access", | 47 | runtimeMode: "full-access", |
| @@ -81,4 +87,76 @@ describe("store pure functions", () => { · 39 unmodified lines | |||
| 81 | 87 | ||
| 82 | expect(next).toEqual(initialState); | 88 | expect(next).toEqual(initialState); |
| 83 | }); | 89 | }); |
| 90 | |||
| 91 | it("moveProject reorders projects and persists the new order", () => { | ||
| 92 | const firstProject = makeProject({ | ||
| 93 | id: ProjectId.makeUnsafe("project-1"), | ||
| 94 | name: "First", | ||
| 95 | cwd: "/tmp/first", | ||
| 96 | }); | ||
| 97 | const secondProject = makeProject({ | ||
| 98 | id: ProjectId.makeUnsafe("project-2"), | ||
| 99 | name: "Second", | ||
| 100 | cwd: "/tmp/second", | ||
| 101 | }); | ||
| 102 | const thirdProject = makeProject({ | ||
| 103 | id: ProjectId.makeUnsafe("project-3"), | ||
| 104 | name: "Third", | ||
| 105 | cwd: "/tmp/third", | ||
| 106 | }); | ||
| 107 | const initialState = makeState(makeThread(), [firstProject, secondProject, thirdProject]); | ||
| 108 | |||
| 109 | const next = moveProject( | ||
| 110 | initialState, | ||
| 111 | ProjectId.makeUnsafe("project-3"), | ||
| 112 | ProjectId.makeUnsafe("project-1"), | ||
| 113 | "before", | ||
| 114 | ); | ||
| 115 | |||
| 116 | expect(next.projects.map((project) => project.id)).toEqual([ | ||
| 117 | ProjectId.makeUnsafe("project-3"), | ||
| 118 | ProjectId.makeUnsafe("project-1"), | ||
| 119 | ProjectId.makeUnsafe("project-2"), | ||
| 120 | ]); | ||
| 121 | expect(next.projectOrder).toEqual(next.projects.map((project) => project.id)); | ||
| 122 | }); | ||
| 123 | |||
| 124 | it("syncServerReadModel reapplies a persisted project order to fresh snapshots", () => { | ||
| 125 | const initialState = { | ||
| 126 | ...makeState(makeThread(), []), | ||
| 127 | projectOrder: [ | ||
| 128 | ProjectId.makeUnsafe("project-2"), | ||
| 129 | ProjectId.makeUnsafe("project-1"), | ||
| 130 | ], | ||
| 131 | }; | ||
| 132 | const readModel = { | ||
| 133 | projects: [ | ||
| 134 | { | ||
| 135 | id: ProjectId.makeUnsafe("project-1"), | ||
| 136 | title: "First", | ||
| 137 | workspaceRoot: "/tmp/first", | ||
| 138 | defaultModel: null, | ||
| 139 | scripts: [], | ||
| 140 | deletedAt: null, | ||
| 141 | }, | ||
| 142 | { | ||
| 143 | id: ProjectId.makeUnsafe("project-2"), | ||
| 144 | title: "Second", | ||
| 145 | workspaceRoot: "/tmp/second", | ||
| 146 | defaultModel: null, | ||
| 147 | scripts: [], | ||
| 148 | deletedAt: null, | ||
| 149 | }, | ||
| 150 | ], | ||
| 151 | threads: [], | ||
| 152 | } as unknown as OrchestrationReadModel; | ||
| 153 | |||
| 154 | const next = syncServerReadModel(initialState, readModel); | ||
| 155 | |||
| 156 | expect(next.projects.map((project) => project.id)).toEqual([ | ||
| 157 | ProjectId.makeUnsafe("project-2"), | ||
| 158 | ProjectId.makeUnsafe("project-1"), | ||
| 159 | ]); | ||
| 160 | expect(next.projectOrder).toEqual(next.projects.map((project) => project.id)); | ||
| 161 | }); | ||
| 84 | }); | 162 | }); |
| @@ -1,6 +1,7 @@ | |||
| 1 | import { Fragment, type ReactNode, createElement, useEffect } from "react"; | 1 | import { Fragment, type ReactNode, createElement, useEffect } from "react"; |
| 2 | import { | 2 | import { |
| 3 | DEFAULT_MODEL, | 3 | DEFAULT_MODEL, |
| 4 | ProjectId, | ||
| 4 | ProviderSessionId, | 5 | ProviderSessionId, |
| 5 | ThreadId, | 6 | ThreadId, |
| 6 | type OrchestrationReadModel, | 7 | type OrchestrationReadModel, |
| @@ -15,11 +16,13 @@ import { · 8 unmodified lines | |||
| 15 | type RuntimeMode, | 16 | type RuntimeMode, |
| 16 | type Thread, | 17 | type Thread, |
| 17 | } from "./types"; | 18 | } from "./types"; |
| 19 | import { resolveServerHttpOrigin } from "./lib/serverOrigin"; | ||
| 18 | 20 | ||
| 19 | // ── State ──────────────────────────────────────────────────────────── | 21 | // ── State ──────────────────────────────────────────────────────────── |
| 20 | 22 | ||
| 21 | export interface AppState { | 23 | export interface AppState { |
| 22 | projects: Project[]; | 24 | projects: Project[]; |
| 25 | projectOrder: Project["id"][]; | ||
| 23 | threads: Thread[]; | 26 | threads: Thread[]; |
| 24 | threadsHydrated: boolean; | 27 | threadsHydrated: boolean; |
| 25 | runtimeMode: RuntimeMode; | 28 | runtimeMode: RuntimeMode; |
| @@ -39,15 +42,19 @@ const LEGACY_PERSISTED_STATE_KEYS = [ · 13 unmodified lines | |||
| 39 | 42 | ||
| 40 | const initialState: AppState = { | 43 | const initialState: AppState = { |
| 41 | projects: [], | 44 | projects: [], |
| 45 | projectOrder: [], | ||
| 42 | threads: [], | 46 | threads: [], |
| 43 | threadsHydrated: false, | 47 | threadsHydrated: false, |
| 44 | runtimeMode: DEFAULT_RUNTIME_MODE, | 48 | runtimeMode: DEFAULT_RUNTIME_MODE, |
| 45 | }; | 49 | }; |
| 46 | const persistedExpandedProjectCwds = new Set<string>(); | 50 | const persistedExpandedProjectCwds = new Set<string>(); |
| 51 | const persistedProjectOrder: Project["id"][] = []; | ||
| 47 | 52 | ||
| 48 | // ── Persist helpers ────────────────────────────────────────────────── | 53 | // ── Persist helpers ────────────────────────────────────────────────── |
| 49 | 54 | ||
| 50 | function readPersistedState(): AppState { | 55 | function readPersistedState(): AppState { |
| 56 | persistedExpandedProjectCwds.clear(); | ||
| 57 | persistedProjectOrder.length = 0; | ||
| 51 | if (typeof window === "undefined") return initialState; | 58 | if (typeof window === "undefined") return initialState; |
| 52 | try { | 59 | try { |
| 53 | const raw = window.localStorage.getItem(PERSISTED_STATE_KEY); | 60 | const raw = window.localStorage.getItem(PERSISTED_STATE_KEY); |
| @@ -55,15 +62,21 @@ function readPersistedState(): AppState { · 1 unmodified line | |||
| 55 | const parsed = JSON.parse(raw) as { | 62 | const parsed = JSON.parse(raw) as { |
| 56 | runtimeMode?: RuntimeMode; | 63 | runtimeMode?: RuntimeMode; |
| 57 | expandedProjectCwds?: string[]; | 64 | expandedProjectCwds?: string[]; |
| 65 | projectOrder?: string[]; | ||
| 58 | }; | 66 | }; |
| 59 | persistedExpandedProjectCwds.clear(); | ||
| 60 | for (const cwd of parsed.expandedProjectCwds ?? []) { | 67 | for (const cwd of parsed.expandedProjectCwds ?? []) { |
| 61 | if (typeof cwd === "string" && cwd.length > 0) { | 68 | if (typeof cwd === "string" && cwd.length > 0) { |
| 62 | persistedExpandedProjectCwds.add(cwd); | 69 | persistedExpandedProjectCwds.add(cwd); |
| 63 | } | 70 | } |
| 64 | } | 71 | } |
| 72 | for (const projectId of parsed.projectOrder ?? []) { | ||
| 73 | if (typeof projectId === "string" && projectId.length > 0) { | ||
| 74 | persistedProjectOrder.push(ProjectId.makeUnsafe(projectId)); | ||
| 75 | } | ||
| 76 | } | ||
| 65 | return { | 77 | return { |
| 66 | ...initialState, | 78 | ...initialState, |
| 79 | projectOrder: [...persistedProjectOrder], | ||
| 67 | runtimeMode: | 80 | runtimeMode: |
| 68 | parsed.runtimeMode === "approval-required" || parsed.runtimeMode === "full-access" | 81 | parsed.runtimeMode === "approval-required" || parsed.runtimeMode === "full-access" |
| 69 | ? parsed.runtimeMode | 82 | ? parsed.runtimeMode |
| @@ -84,6 +97,7 @@ function persistState(state: AppState): void { · 14 unmodified lines | |||
| 84 | expandedProjectCwds: state.projects | 97 | expandedProjectCwds: state.projects |
| 85 | .filter((project) => project.expanded) | 98 | .filter((project) => project.expanded) |
| 86 | .map((project) => project.cwd), | 99 | .map((project) => project.cwd), |
| 100 | projectOrder: state.projects.map((project) => project.id), | ||
| 87 | }), | 101 | }), |
| 88 | ); | 102 | ); |
| 89 | for (const legacyKey of LEGACY_PERSISTED_STATE_KEYS) { | 103 | for (const legacyKey of LEGACY_PERSISTED_STATE_KEYS) { |
| @@ -134,6 +148,28 @@ function mapProjectsFromReadModel( · 44 unmodified lines | |||
| 134 | }); | 148 | }); |
| 135 | } | 149 | } |
| 136 | 150 | ||
| 151 | function applyProjectOrder(projects: Project[], projectOrder: Project["id"][]): Project[] { | ||
| 152 | if (projects.length < 2 || projectOrder.length === 0) { | ||
| 153 | return projects; | ||
| 154 | } | ||
| 155 | |||
| 156 | const remainingProjects = new Map(projects.map((project) => [project.id, project] as const)); | ||
| 157 | const orderedProjects: Project[] = []; | ||
| 158 | for (const projectId of projectOrder) { | ||
| 159 | const project = remainingProjects.get(projectId); | ||
| 160 | if (!project) continue; | ||
| 161 | orderedProjects.push(project); | ||
| 162 | remainingProjects.delete(projectId); | ||
| 163 | } | ||
| 164 | |||
| 165 | const nextProjects = [ | ||
| 166 | ...orderedProjects, | ||
| 167 | ...projects.filter((project) => remainingProjects.has(project.id)), | ||
| 168 | ]; | ||
| 169 | const orderChanged = nextProjects.some((project, index) => project !== projects[index]); | ||
| 170 | return orderChanged ? nextProjects : projects; | ||
| 171 | } | ||
| 172 | |||
| 137 | function toLegacySessionStatus( | 173 | function toLegacySessionStatus( |
| 138 | status: OrchestrationSessionStatus, | 174 | status: OrchestrationSessionStatus, |
| 139 | ): "connecting" | "ready" | "running" | "error" | "closed" { | 175 | ): "connecting" | "ready" | "running" | "error" | "closed" { |
| @@ -157,34 +193,9 @@ function toLegacyProvider(providerName: string | null): "codex" | "claudeCode" { · 17 unmodified lines | |||
| 157 | return providerName === "claudeCode" ? "claudeCode" : "codex"; | 193 | return providerName === "claudeCode" ? "claudeCode" : "codex"; |
| 158 | } | 194 | } |
| 159 | 195 | ||
| 160 | function resolveWsHttpOrigin(): string { | ||
| 161 | if (typeof window === "undefined") return ""; | ||
| 162 | const bridgeWsUrl = window.desktopBridge?.getWsUrl?.(); | ||
| 163 | const envWsUrl = import.meta.env.VITE_WS_URL as string | undefined; | ||
| 164 | const wsCandidate = | ||
| 165 | typeof bridgeWsUrl === "string" && bridgeWsUrl.length > 0 | ||
| 166 | ? bridgeWsUrl | ||
| 167 | : typeof envWsUrl === "string" && envWsUrl.length > 0 | ||
| 168 | ? envWsUrl | ||
| 169 | : null; | ||
| 170 | if (!wsCandidate) return window.location.origin; | ||
| 171 | try { | ||
| 172 | const wsUrl = new URL(wsCandidate); | ||
| 173 | const protocol = | ||
| 174 | wsUrl.protocol === "wss:" | ||
| 175 | ? "https:" | ||
| 176 | : wsUrl.protocol === "ws:" | ||
| 177 | ? "http:" | ||
| 178 | : wsUrl.protocol; | ||
| 179 | return `${protocol}//${wsUrl.host}`; | ||
| 180 | } catch { | ||
| 181 | return window.location.origin; | ||
| 182 | } | ||
| 183 | } | ||
| 184 | |||
| 185 | function toAttachmentPreviewUrl(rawUrl: string): string { | 196 | function toAttachmentPreviewUrl(rawUrl: string): string { |
| 186 | if (rawUrl.startsWith("/")) { | 197 | if (rawUrl.startsWith("/")) { |
| 187 | return `${resolveWsHttpOrigin()}${rawUrl}`; | 198 | return `${resolveServerHttpOrigin()}${rawUrl}`; |
| 188 | } | 199 | } |
| 189 | return rawUrl; | 200 | return rawUrl; |
| 190 | } | 201 | } |
| @@ -199,9 +210,10 @@ export function syncServerReadModel( · 8 unmodified lines | |||
| 199 | state: AppState, | 210 | state: AppState, |
| 200 | readModel: OrchestrationReadModel, | 211 | readModel: OrchestrationReadModel, |
| 201 | ): AppState { | 212 | ): AppState { |
| 202 | const projects = mapProjectsFromReadModel( | 213 | const activeReadModelProjects = readModel.projects.filter((project) => project.deletedAt === null); |
| 203 | readModel.projects.filter((project) => project.deletedAt === null), | 214 | const projects = applyProjectOrder( |
| 204 | state.projects, | 215 | mapProjectsFromReadModel(activeReadModelProjects, state.projects), |
| 216 | state.projectOrder, | ||
| 205 | ); | 217 | ); |
| 206 | const existingThreadById = new Map( | 218 | const existingThreadById = new Map( |
| 207 | state.threads.map((thread) => [thread.id, thread] as const), | 219 | state.threads.map((thread) => [thread.id, thread] as const), |
| @@ -272,6 +284,7 @@ export function syncServerReadModel( · 64 unmodified lines | |||
| 272 | return { | 284 | return { |
| 273 | ...state, | 285 | ...state, |
| 274 | projects, | 286 | projects, |
| 287 | projectOrder: projects.map((project) => project.id), | ||
| 275 | threads, | 288 | threads, |
| 276 | threadsHydrated: true, | 289 | threadsHydrated: true, |
| 277 | }; | 290 | }; |
| @@ -333,6 +346,38 @@ export function setProjectExpanded( · 55 unmodified lines | |||
| 333 | return changed ? { ...state, projects } : state; | 346 | return changed ? { ...state, projects } : state; |
| 334 | } | 347 | } |
| 335 | 348 | ||
| 349 | export function moveProject( | ||
| 350 | state: AppState, | ||
| 351 | projectId: Project["id"], | ||
| 352 | targetProjectId: Project["id"], | ||
| 353 | position: "before" | "after", | ||
| 354 | ): AppState { | ||
| 355 | if (projectId === targetProjectId) return state; | ||
| 356 | |||
| 357 | const currentIndex = state.projects.findIndex((project) => project.id === projectId); | ||
| 358 | const targetIndex = state.projects.findIndex((project) => project.id === targetProjectId); | ||
| 359 | if (currentIndex < 0 || targetIndex < 0) return state; | ||
| 360 | |||
| 361 | const projects = [...state.projects]; | ||
| 362 | const [project] = projects.splice(currentIndex, 1); | ||
| 363 | if (!project) return state; | ||
| 364 | |||
| 365 | const adjustedTargetIndex = projects.findIndex((entry) => entry.id === targetProjectId); | ||
| 366 | if (adjustedTargetIndex < 0) return state; | ||
| 367 | |||
| 368 | const insertionIndex = position === "before" ? adjustedTargetIndex : adjustedTargetIndex + 1; | ||
| 369 | projects.splice(insertionIndex, 0, project); | ||
| 370 | |||
| 371 | const orderChanged = projects.some((entry, index) => entry !== state.projects[index]); | ||
| 372 | if (!orderChanged) return state; | ||
| 373 | |||
| 374 | return { | ||
| 375 | ...state, | ||
| 376 | projects, | ||
| 377 | projectOrder: projects.map((entry) => entry.id), | ||
| 378 | }; | ||
| 379 | } | ||
| 380 | |||
| 336 | export function setError(state: AppState, threadId: ThreadId, error: string | null): AppState { | 381 | export function setError(state: AppState, threadId: ThreadId, error: string | null): AppState { |
| 337 | const threads = updateThread(state.threads, threadId, (t) => { | 382 | const threads = updateThread(state.threads, threadId, (t) => { |
| 338 | if (t.error === error) return t; | 383 | if (t.error === error) return t; |
| @@ -372,6 +417,11 @@ interface AppStore extends AppState { · 33 unmodified lines | |||
| 372 | markThreadVisited: (threadId: ThreadId, visitedAt?: string) => void; | 417 | markThreadVisited: (threadId: ThreadId, visitedAt?: string) => void; |
| 373 | markThreadUnread: (threadId: ThreadId) => void; | 418 | markThreadUnread: (threadId: ThreadId) => void; |
| 374 | toggleProject: (projectId: Project["id"]) => void; | 419 | toggleProject: (projectId: Project["id"]) => void; |
| 420 | moveProject: ( | ||
| 421 | projectId: Project["id"], | ||
| 422 | targetProjectId: Project["id"], | ||
| 423 | position: "before" | "after", | ||
| 424 | ) => void; | ||
| 375 | setProjectExpanded: (projectId: Project["id"], expanded: boolean) => void; | 425 | setProjectExpanded: (projectId: Project["id"], expanded: boolean) => void; |
| 376 | setError: (threadId: ThreadId, error: string | null) => void; | 426 | setError: (threadId: ThreadId, error: string | null) => void; |
| 377 | setThreadBranch: ( | 427 | setThreadBranch: ( |
| @@ -392,6 +442,8 @@ export const useStore = create<AppStore>((set) => ({ · 14 unmodified lines | |||
| 392 | set((state) => markThreadUnread(state, threadId)), | 442 | set((state) => markThreadUnread(state, threadId)), |
| 393 | toggleProject: (projectId) => | 443 | toggleProject: (projectId) => |
| 394 | set((state) => toggleProject(state, projectId)), | 444 | set((state) => toggleProject(state, projectId)), |
| 445 | moveProject: (projectId, targetProjectId, position) => | ||
| 446 | set((state) => moveProject(state, projectId, targetProjectId, position)), | ||
| 395 | setProjectExpanded: (projectId, expanded) => | 447 | setProjectExpanded: (projectId, expanded) => |
| 396 | set((state) => setProjectExpanded(state, projectId, expanded)), | 448 | set((state) => setProjectExpanded(state, projectId, expanded)), |
| 397 | setError: (threadId, error) => | 449 | setError: (threadId, error) => |
| @@ -402,7 +454,7 @@ export const useStore = create<AppStore>((set) => ({ · 4 unmodified lines | |||
| 402 | set((state) => setRuntimeMode(state, mode)), | 454 | set((state) => setRuntimeMode(state, mode)), |
| 403 | })); | 455 | })); |
| 404 | 456 | ||
| 405 | // Persist on every state change (only runtimeMode + expandedProjectCwds) | 457 | // Persist on every state change (runtimeMode + expanded projects + project order) |
| 406 | useStore.subscribe((state) => persistState(state)); | 458 | useStore.subscribe((state) => persistState(state)); |
| 407 | 459 | ||
| 408 | export function StoreProvider({ children }: { children: ReactNode }) { | 460 | export function StoreProvider({ children }: { children: ReactNode }) { |