Unified · Split
| @@ -1,12 +1,13 @@ | ||
| 1 | 1 | import { |
| 2 | 2 | ChevronRightIcon, |
| 3 | 3 | FolderIcon, |
| 4 | + GripVerticalIcon, | |
| 4 | 5 | GitPullRequestIcon, |
| 5 | 6 | RocketIcon, |
| 6 | 7 | SquarePenIcon, |
| 7 | 8 | TerminalIcon, |
| 8 | 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 | 11 | import { |
| 11 | 12 | DEFAULT_MODEL, |
| 12 | 13 | type DesktopUpdateState, |
| @@ -20,6 +21,7 @@ import { useNavigate, useParams } from "@tanstack/react-router"; · 7 unmodified lines | ||
| 20 | 21 | import { useAppSettings } from "../appSettings"; |
| 21 | 22 | import { isElectron } from "../env"; |
| 22 | 23 | import { APP_STAGE_LABEL } from "../branding"; |
| 24 | +import { resolveServerHttpOrigin } from "../lib/serverOrigin"; | |
| 23 | 25 | import { newCommandId, newProjectId, newThreadId } from "../lib/utils"; |
| 24 | 26 | import { useStore } from "../store"; |
| 25 | 27 | import { isChatNewLocalShortcut, isChatNewShortcut, shortcutLabelForCommand } from "../keybindings"; |
| @@ -80,6 +82,11 @@ function formatRelativeTime(iso: string): string { · 54 unmodified lines | ||
| 80 | 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 | 90 | interface ThreadStatusPill { |
| 84 | 91 | label: "Working" | "Connecting" | "Completed" | "Pending Approval"; |
| 85 | 92 | colorClass: string; |
| @@ -212,29 +219,7 @@ function T3Wordmark() { · 126 unmodified lines | ||
| 212 | 219 | ); |
| 213 | 220 | } |
| 214 | 221 | |
| 215 | −/** | |
| 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(); | |
| 222 | +const serverHttpOrigin = resolveServerHttpOrigin(); | |
| 238 | 223 | |
| 239 | 224 | function ProjectFavicon({ cwd }: { cwd: string }) { |
| 240 | 225 | const [status, setStatus] = useState<"loading" | "loaded" | "error">("loading"); |
| @@ -260,6 +245,7 @@ export default function Sidebar() { · 19 unmodified lines | ||
| 260 | 245 | const projects = useStore((store) => store.projects); |
| 261 | 246 | const threads = useStore((store) => store.threads); |
| 262 | 247 | const markThreadUnread = useStore((store) => store.markThreadUnread); |
| 248 | + const moveProject = useStore((store) => store.moveProject); | |
| 263 | 249 | const toggleProject = useStore((store) => store.toggleProject); |
| 264 | 250 | const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearThreadDraft); |
| 265 | 251 | const getDraftThreadByProjectId = useComposerDraftStore( |
| @@ -294,6 +280,11 @@ export default function Sidebar() { · 28 unmodified lines | ||
| 294 | 280 | const [isAddingProject, setIsAddingProject] = useState(false); |
| 295 | 281 | const [renamingThreadId, setRenamingThreadId] = useState<ThreadId | null>(null); |
| 296 | 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 | 288 | const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState< |
| 298 | 289 | ReadonlySet<ProjectId> |
| 299 | 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 | 1020 | const wordmark = ( |
| 971 | 1021 | <div className="flex items-center gap-2"> |
| 972 | 1022 | <SidebarTrigger className="shrink-0 md:hidden" /> |
| @@ -1032,6 +1082,12 @@ export default function Sidebar() { · 59 unmodified lines | ||
| 1032 | 1082 | hasHiddenThreads && !isThreadListExpanded |
| 1033 | 1083 | ? projectThreads.slice(0, THREAD_PREVIEW_LIMIT) |
| 1034 | 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 | 1092 | return ( |
| 1037 | 1093 | <Collapsible |
| @@ -1043,13 +1099,39 @@ export default function Sidebar() { · 5 unmodified lines | ||
| 1043 | 1099 | toggleProject(project.id); |
| 1044 | 1100 | }} |
| 1045 | 1101 | > |
| 1046 | − <SidebarMenuItem> | |
| 1047 | − <div className="group/project-header relative"> | |
| 1102 | + <SidebarMenuItem className={draggedProjectId === project.id ? "opacity-60" : ""}> | |
| 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 | 1130 | <CollapsibleTrigger |
| 1049 | 1131 | render={ |
| 1050 | 1132 | <SidebarMenuButton |
| 1051 | 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 | 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 | 2 | import { ProjectId, ThreadId, TurnId } from "@t3tools/contracts"; |
| 2 | 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 | 7 | import type { Thread } from "./types"; |
| 6 | 8 | |
| 7 | 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 | 30 | return { |
| 29 | − projects: [ | |
| 30 | − { | |
| 31 | − id: ProjectId.makeUnsafe("project-1"), | |
| 32 | − name: "Project", | |
| 33 | − cwd: "/tmp/project", | |
| 34 | − model: "gpt-5-codex", | |
| 35 | − expanded: true, | |
| 36 | − scripts: [], | |
| 37 | − }, | |
| 38 | − ], | |
| 31 | + id: ProjectId.makeUnsafe("project-1"), | |
| 32 | + name: "Project", | |
| 33 | + cwd: "/tmp/project", | |
| 34 | + model: "gpt-5-codex", | |
| 35 | + expanded: true, | |
| 36 | + scripts: [], | |
| 37 | + ...overrides, | |
| 38 | + }; | |
| 39 | +} | |
| 40 | + | |
| 41 | +function makeState(thread: Thread, projects: Project[] = [makeProject()]): AppState { | |
| 42 | + return { | |
| 43 | + projects, | |
| 44 | + projectOrder: projects.map((project) => project.id), | |
| 39 | 45 | threads: [thread], |
| 40 | 46 | threadsHydrated: true, |
| 41 | 47 | runtimeMode: "full-access", |
| @@ -81,4 +87,76 @@ describe("store pure functions", () => { · 39 unmodified lines | ||
| 81 | 87 | |
| 82 | 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 | 1 | import { Fragment, type ReactNode, createElement, useEffect } from "react"; |
| 2 | 2 | import { |
| 3 | 3 | DEFAULT_MODEL, |
| 4 | + ProjectId, | |
| 4 | 5 | ProviderSessionId, |
| 5 | 6 | ThreadId, |
| 6 | 7 | type OrchestrationReadModel, |
| @@ -15,11 +16,13 @@ import { · 8 unmodified lines | ||
| 15 | 16 | type RuntimeMode, |
| 16 | 17 | type Thread, |
| 17 | 18 | } from "./types"; |
| 19 | +import { resolveServerHttpOrigin } from "./lib/serverOrigin"; | |
| 18 | 20 | |
| 19 | 21 | // ── State ──────────────────────────────────────────────────────────── |
| 20 | 22 | |
| 21 | 23 | export interface AppState { |
| 22 | 24 | projects: Project[]; |
| 25 | + projectOrder: Project["id"][]; | |
| 23 | 26 | threads: Thread[]; |
| 24 | 27 | threadsHydrated: boolean; |
| 25 | 28 | runtimeMode: RuntimeMode; |
| @@ -39,15 +42,19 @@ const LEGACY_PERSISTED_STATE_KEYS = [ · 13 unmodified lines | ||
| 39 | 42 | |
| 40 | 43 | const initialState: AppState = { |
| 41 | 44 | projects: [], |
| 45 | + projectOrder: [], | |
| 42 | 46 | threads: [], |
| 43 | 47 | threadsHydrated: false, |
| 44 | 48 | runtimeMode: DEFAULT_RUNTIME_MODE, |
| 45 | 49 | }; |
| 46 | 50 | const persistedExpandedProjectCwds = new Set<string>(); |
| 51 | +const persistedProjectOrder: Project["id"][] = []; | |
| 47 | 52 | |
| 48 | 53 | // ── Persist helpers ────────────────────────────────────────────────── |
| 49 | 54 | |
| 50 | 55 | function readPersistedState(): AppState { |
| 56 | + persistedExpandedProjectCwds.clear(); | |
| 57 | + persistedProjectOrder.length = 0; | |
| 51 | 58 | if (typeof window === "undefined") return initialState; |
| 52 | 59 | try { |
| 53 | 60 | const raw = window.localStorage.getItem(PERSISTED_STATE_KEY); |
| @@ -55,15 +62,21 @@ function readPersistedState(): AppState { · 1 unmodified line | ||
| 55 | 62 | const parsed = JSON.parse(raw) as { |
| 56 | 63 | runtimeMode?: RuntimeMode; |
| 57 | 64 | expandedProjectCwds?: string[]; |
| 65 | + projectOrder?: string[]; | |
| 58 | 66 | }; |
| 59 | − persistedExpandedProjectCwds.clear(); | |
| 60 | 67 | for (const cwd of parsed.expandedProjectCwds ?? []) { |
| 61 | 68 | if (typeof cwd === "string" && cwd.length > 0) { |
| 62 | 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 | 77 | return { |
| 66 | 78 | ...initialState, |
| 79 | + projectOrder: [...persistedProjectOrder], | |
| 67 | 80 | runtimeMode: |
| 68 | 81 | parsed.runtimeMode === "approval-required" || parsed.runtimeMode === "full-access" |
| 69 | 82 | ? parsed.runtimeMode |
| @@ -84,6 +97,7 @@ function persistState(state: AppState): void { · 14 unmodified lines | ||
| 84 | 97 | expandedProjectCwds: state.projects |
| 85 | 98 | .filter((project) => project.expanded) |
| 86 | 99 | .map((project) => project.cwd), |
| 100 | + projectOrder: state.projects.map((project) => project.id), | |
| 87 | 101 | }), |
| 88 | 102 | ); |
| 89 | 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 | 173 | function toLegacySessionStatus( |
| 138 | 174 | status: OrchestrationSessionStatus, |
| 139 | 175 | ): "connecting" | "ready" | "running" | "error" | "closed" { |
| @@ -157,34 +193,9 @@ function toLegacyProvider(providerName: string | null): "codex" | "claudeCode" { · 17 unmodified lines | ||
| 157 | 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 | 196 | function toAttachmentPreviewUrl(rawUrl: string): string { |
| 186 | 197 | if (rawUrl.startsWith("/")) { |
| 187 | − return `${resolveWsHttpOrigin()}${rawUrl}`; | |
| 198 | + return `${resolveServerHttpOrigin()}${rawUrl}`; | |
| 188 | 199 | } |
| 189 | 200 | return rawUrl; |
| 190 | 201 | } |
| @@ -199,9 +210,10 @@ export function syncServerReadModel( · 8 unmodified lines | ||
| 199 | 210 | state: AppState, |
| 200 | 211 | readModel: OrchestrationReadModel, |
| 201 | 212 | ): AppState { |
| 202 | − const projects = mapProjectsFromReadModel( | |
| 203 | − readModel.projects.filter((project) => project.deletedAt === null), | |
| 204 | − state.projects, | |
| 213 | + const activeReadModelProjects = readModel.projects.filter((project) => project.deletedAt === null); | |
| 214 | + const projects = applyProjectOrder( | |
| 215 | + mapProjectsFromReadModel(activeReadModelProjects, state.projects), | |
| 216 | + state.projectOrder, | |
| 205 | 217 | ); |
| 206 | 218 | const existingThreadById = new Map( |
| 207 | 219 | state.threads.map((thread) => [thread.id, thread] as const), |
| @@ -272,6 +284,7 @@ export function syncServerReadModel( · 64 unmodified lines | ||
| 272 | 284 | return { |
| 273 | 285 | ...state, |
| 274 | 286 | projects, |
| 287 | + projectOrder: projects.map((project) => project.id), | |
| 275 | 288 | threads, |
| 276 | 289 | threadsHydrated: true, |
| 277 | 290 | }; |
| @@ -333,6 +346,38 @@ export function setProjectExpanded( · 55 unmodified lines | ||
| 333 | 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 | 381 | export function setError(state: AppState, threadId: ThreadId, error: string | null): AppState { |
| 337 | 382 | const threads = updateThread(state.threads, threadId, (t) => { |
| 338 | 383 | if (t.error === error) return t; |
| @@ -372,6 +417,11 @@ interface AppStore extends AppState { · 33 unmodified lines | ||
| 372 | 417 | markThreadVisited: (threadId: ThreadId, visitedAt?: string) => void; |
| 373 | 418 | markThreadUnread: (threadId: ThreadId) => void; |
| 374 | 419 | toggleProject: (projectId: Project["id"]) => void; |
| 420 | + moveProject: ( | |
| 421 | + projectId: Project["id"], | |
| 422 | + targetProjectId: Project["id"], | |
| 423 | + position: "before" | "after", | |
| 424 | + ) => void; | |
| 375 | 425 | setProjectExpanded: (projectId: Project["id"], expanded: boolean) => void; |
| 376 | 426 | setError: (threadId: ThreadId, error: string | null) => void; |
| 377 | 427 | setThreadBranch: ( |
| @@ -392,6 +442,8 @@ export const useStore = create<AppStore>((set) => ({ · 14 unmodified lines | ||
| 392 | 442 | set((state) => markThreadUnread(state, threadId)), |
| 393 | 443 | toggleProject: (projectId) => |
| 394 | 444 | set((state) => toggleProject(state, projectId)), |
| 445 | + moveProject: (projectId, targetProjectId, position) => | |
| 446 | + set((state) => moveProject(state, projectId, targetProjectId, position)), | |
| 395 | 447 | setProjectExpanded: (projectId, expanded) => |
| 396 | 448 | set((state) => setProjectExpanded(state, projectId, expanded)), |
| 397 | 449 | setError: (threadId, error) => |
| @@ -402,7 +454,7 @@ export const useStore = create<AppStore>((set) => ({ · 4 unmodified lines | ||
| 402 | 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 | 458 | useStore.subscribe((state) => persistState(state)); |
| 407 | 459 | |
| 408 | 460 | export function StoreProvider({ children }: { children: ReactNode }) { |