| author | |
| committer | |
| log | ffbf541ff88c2c8398e69612fb65d8705ab2ad60 |
| tree | 43bd2e13e293fdb6f9033ba164384f74540cac28 |
| parent | 709f961e1c9261efd43673a0e802c6cd0bfca8f1 |
- move `/api/project-favicon` handling into a dedicated server route with coverage for root and source-linked icons
- wire `wsServer` to use the new route helper
- treat any running session as unsettled in `isLatestTurnSettled` to prevent stale latest-turn banners, with test coverage5 files changed, 291 insertions(+), 156 deletions(-)
apps/server/src/projectFaviconRoute.test.ts created+118| ... | @@ -0,0 +1,118 @@ | ||
| 1 | import fs from "node:fs"; | ||
| 2 | import http from "node:http"; | ||
| 3 | import os from "node:os"; | ||
| 4 | import path from "node:path"; | ||
| 5 | |||
| 6 | import { afterEach, describe, expect, it } from "vitest"; | ||
| 7 | import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute"; | ||
| 8 | |||
| 9 | interface HttpResponse { | ||
| 10 | statusCode: number; | ||
| 11 | contentType: string | null; | ||
| 12 | body: string; | ||
| 13 | } | ||
| 14 | |||
| 15 | const tempDirs: string[] = []; | ||
| 16 | |||
| 17 | function makeTempDir(prefix: string): string { | ||
| 18 | const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); | ||
| 19 | tempDirs.push(dir); | ||
| 20 | return dir; | ||
| 21 | } | ||
| 22 | |||
| 23 | async function withRouteServer( | ||
| 24 | run: (baseUrl: string) => Promise<void>, | ||
| 25 | ): Promise<void> { | ||
| 26 | const server = http.createServer((req, res) => { | ||
| 27 | const url = new URL(req.url ?? "/", "http://127.0.0.1"); | ||
| 28 | if (tryHandleProjectFaviconRequest(url, res)) { | ||
| 29 | return; | ||
| 30 | } | ||
| 31 | res.writeHead(404, { "Content-Type": "text/plain" }); | ||
| 32 | res.end("Not Found"); | ||
| 33 | }); | ||
| 34 | |||
| 35 | await new Promise<void>((resolve, reject) => { | ||
| 36 | server.listen(0, "127.0.0.1", (error?: Error) => { | ||
| 37 | if (error) { | ||
| 38 | reject(error); | ||
| 39 | return; | ||
| 40 | } | ||
| 41 | resolve(); | ||
| 42 | }); | ||
| 43 | }); | ||
| 44 | |||
| 45 | const address = server.address(); | ||
| 46 | if (typeof address !== "object" || address === null) { | ||
| 47 | throw new Error("Expected server address to be an object"); | ||
| 48 | } | ||
| 49 | const baseUrl = `http://127.0.0.1:${address.port}`; | ||
| 50 | |||
| 51 | try { | ||
| 52 | await run(baseUrl); | ||
| 53 | } finally { | ||
| 54 | await new Promise<void>((resolve, reject) => { | ||
| 55 | server.close((error?: Error) => { | ||
| 56 | if (error) { | ||
| 57 | reject(error); | ||
| 58 | return; | ||
| 59 | } | ||
| 60 | resolve(); | ||
| 61 | }); | ||
| 62 | }); | ||
| 63 | } | ||
| 64 | } | ||
| 65 | |||
| 66 | async function request(baseUrl: string, pathname: string): Promise<HttpResponse> { | ||
| 67 | const response = await fetch(`${baseUrl}${pathname}`); | ||
| 68 | return { | ||
| 69 | statusCode: response.status, | ||
| 70 | contentType: response.headers.get("content-type"), | ||
| 71 | body: await response.text(), | ||
| 72 | }; | ||
| 73 | } | ||
| 74 | |||
| 75 | describe("tryHandleProjectFaviconRequest", () => { | ||
| 76 | afterEach(() => { | ||
| 77 | for (const dir of tempDirs.splice(0, tempDirs.length)) { | ||
| 78 | fs.rmSync(dir, { recursive: true, force: true }); | ||
| 79 | } | ||
| 80 | }); | ||
| 81 | |||
| 82 | it("returns 400 when cwd is missing", async () => { | ||
| 83 | await withRouteServer(async (baseUrl) => { | ||
| 84 | const response = await request(baseUrl, "/api/project-favicon"); | ||
| 85 | expect(response.statusCode).toBe(400); | ||
| 86 | expect(response.body).toBe("Missing cwd parameter"); | ||
| 87 | }); | ||
| 88 | }); | ||
| 89 | |||
| 90 | it("serves a well-known favicon file from the project root", async () => { | ||
| 91 | const projectDir = makeTempDir("t3code-favicon-route-root-"); | ||
| 92 | fs.writeFileSync(path.join(projectDir, "favicon.svg"), "<svg>favicon</svg>", "utf8"); | ||
| 93 | |||
| 94 | await withRouteServer(async (baseUrl) => { | ||
| 95 | const pathname = `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`; | ||
| 96 | const response = await request(baseUrl, pathname); | ||
| 97 | expect(response.statusCode).toBe(200); | ||
| 98 | expect(response.contentType).toContain("image/svg+xml"); | ||
| 99 | expect(response.body).toBe("<svg>favicon</svg>"); | ||
| 100 | }); | ||
| 101 | }); | ||
| 102 | |||
| 103 | it("resolves icon href from source files when no well-known favicon exists", async () => { | ||
| 104 | const projectDir = makeTempDir("t3code-favicon-route-source-"); | ||
| 105 | const iconPath = path.join(projectDir, "public", "brand", "logo.svg"); | ||
| 106 | fs.mkdirSync(path.dirname(iconPath), { recursive: true }); | ||
| 107 | fs.writeFileSync(path.join(projectDir, "index.html"), '<link rel="icon" href="/brand/logo.svg">'); | ||
| 108 | fs.writeFileSync(iconPath, "<svg>brand</svg>", "utf8"); | ||
| 109 | |||
| 110 | await withRouteServer(async (baseUrl) => { | ||
| 111 | const pathname = `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`; | ||
| 112 | const response = await request(baseUrl, pathname); | ||
| 113 | expect(response.statusCode).toBe(200); | ||
| 114 | expect(response.contentType).toContain("image/svg+xml"); | ||
| 115 | expect(response.body).toBe("<svg>brand</svg>"); | ||
| 116 | }); | ||
| 117 | }); | ||
| 118 | }); | ||
apps/server/src/projectFaviconRoute.ts created+160| ... | @@ -0,0 +1,160 @@ | ||
| 1 | import fs from "node:fs"; | ||
| 2 | import http from "node:http"; | ||
| 3 | import path from "node:path"; | ||
| 4 | |||
| 5 | const FAVICON_MIME_TYPES: Record<string, string> = { | ||
| 6 | ".png": "image/png", | ||
| 7 | ".jpg": "image/jpeg", | ||
| 8 | ".svg": "image/svg+xml", | ||
| 9 | ".ico": "image/x-icon", | ||
| 10 | }; | ||
| 11 | |||
| 12 | // Well-known favicon paths checked in order. | ||
| 13 | const FAVICON_CANDIDATES = [ | ||
| 14 | "favicon.svg", | ||
| 15 | "favicon.ico", | ||
| 16 | "favicon.png", | ||
| 17 | "public/favicon.svg", | ||
| 18 | "public/favicon.ico", | ||
| 19 | "public/favicon.png", | ||
| 20 | "app/favicon.ico", | ||
| 21 | "app/favicon.png", | ||
| 22 | "app/icon.svg", | ||
| 23 | "app/icon.png", | ||
| 24 | "app/icon.ico", | ||
| 25 | "src/favicon.ico", | ||
| 26 | "src/favicon.svg", | ||
| 27 | "src/app/favicon.ico", | ||
| 28 | "src/app/icon.svg", | ||
| 29 | "src/app/icon.png", | ||
| 30 | ]; | ||
| 31 | |||
| 32 | // Files that may contain a <link rel="icon"> or icon metadata declaration. | ||
| 33 | const ICON_SOURCE_FILES = [ | ||
| 34 | "index.html", | ||
| 35 | "public/index.html", | ||
| 36 | "app/routes/__root.tsx", | ||
| 37 | "src/routes/__root.tsx", | ||
| 38 | "app/root.tsx", | ||
| 39 | "src/root.tsx", | ||
| 40 | "src/index.html", | ||
| 41 | ]; | ||
| 42 | |||
| 43 | // Matches <link rel="icon" href="..."> or { rel: "icon", href: "..." } patterns. | ||
| 44 | const LINK_ICON_HTML_RE = /<link[^>]*rel=["'](?:icon|shortcut icon)["'][^>]*href=["']([^"'?]+)/i; | ||
| 45 | const LINK_ICON_OBJ_RE = /rel:\s*["'](?:icon|shortcut icon)["'][^}]*href:\s*["']([^"'?]+)/i; | ||
| 46 | |||
| 47 | function extractIconHref(source: string): string | null { | ||
| 48 | const htmlMatch = source.match(LINK_ICON_HTML_RE); | ||
| 49 | if (htmlMatch?.[1]) return htmlMatch[1]; | ||
| 50 | const objMatch = source.match(LINK_ICON_OBJ_RE); | ||
| 51 | if (objMatch?.[1]) return objMatch[1]; | ||
| 52 | return null; | ||
| 53 | } | ||
| 54 | |||
| 55 | function resolveIconHref(projectCwd: string, href: string): string[] { | ||
| 56 | const clean = href.replace(/^\//, ""); | ||
| 57 | return [path.join(projectCwd, "public", clean), path.join(projectCwd, clean)]; | ||
| 58 | } | ||
| 59 | |||
| 60 | function isPathWithinProject(projectCwd: string, candidatePath: string): boolean { | ||
| 61 | const relative = path.relative(path.resolve(projectCwd), path.resolve(candidatePath)); | ||
| 62 | return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); | ||
| 63 | } | ||
| 64 | |||
| 65 | function serveFaviconFile(filePath: string, res: http.ServerResponse): void { | ||
| 66 | const ext = path.extname(filePath).toLowerCase(); | ||
| 67 | const contentType = FAVICON_MIME_TYPES[ext] ?? "application/octet-stream"; | ||
| 68 | fs.readFile(filePath, (readErr, data) => { | ||
| 69 | if (readErr) { | ||
| 70 | res.writeHead(500, { "Content-Type": "text/plain" }); | ||
| 71 | res.end("Read error"); | ||
| 72 | return; | ||
| 73 | } | ||
| 74 | res.writeHead(200, { | ||
| 75 | "Content-Type": contentType, | ||
| 76 | "Cache-Control": "public, max-age=3600", | ||
| 77 | }); | ||
| 78 | res.end(data); | ||
| 79 | }); | ||
| 80 | } | ||
| 81 | |||
| 82 | export function tryHandleProjectFaviconRequest(url: URL, res: http.ServerResponse): boolean { | ||
| 83 | if (url.pathname !== "/api/project-favicon") { | ||
| 84 | return false; | ||
| 85 | } | ||
| 86 | |||
| 87 | const projectCwd = url.searchParams.get("cwd"); | ||
| 88 | if (!projectCwd) { | ||
| 89 | res.writeHead(400, { "Content-Type": "text/plain" }); | ||
| 90 | res.end("Missing cwd parameter"); | ||
| 91 | return true; | ||
| 92 | } | ||
| 93 | |||
| 94 | const tryResolvedPaths = ( | ||
| 95 | paths: string[], | ||
| 96 | index: number, | ||
| 97 | onExhausted: () => void, | ||
| 98 | ): void => { | ||
| 99 | if (index >= paths.length) { | ||
| 100 | onExhausted(); | ||
| 101 | return; | ||
| 102 | } | ||
| 103 | const candidate = paths[index]!; | ||
| 104 | if (!isPathWithinProject(projectCwd, candidate)) { | ||
| 105 | tryResolvedPaths(paths, index + 1, onExhausted); | ||
| 106 | return; | ||
| 107 | } | ||
| 108 | fs.stat(candidate, (err, stats) => { | ||
| 109 | if (err || !stats?.isFile()) { | ||
| 110 | tryResolvedPaths(paths, index + 1, onExhausted); | ||
| 111 | return; | ||
| 112 | } | ||
| 113 | serveFaviconFile(candidate, res); | ||
| 114 | }); | ||
| 115 | }; | ||
| 116 | |||
| 117 | const trySourceFiles = (index: number): void => { | ||
| 118 | if (index >= ICON_SOURCE_FILES.length) { | ||
| 119 | res.writeHead(404, { "Content-Type": "text/plain" }); | ||
| 120 | res.end("No favicon found"); | ||
| 121 | return; | ||
| 122 | } | ||
| 123 | const sourceFile = path.join(projectCwd, ICON_SOURCE_FILES[index]!); | ||
| 124 | fs.readFile(sourceFile, "utf8", (err, content) => { | ||
| 125 | if (err) { | ||
| 126 | trySourceFiles(index + 1); | ||
| 127 | return; | ||
| 128 | } | ||
| 129 | const href = extractIconHref(content); | ||
| 130 | if (!href) { | ||
| 131 | trySourceFiles(index + 1); | ||
| 132 | return; | ||
| 133 | } | ||
| 134 | const candidates = resolveIconHref(projectCwd, href); | ||
| 135 | tryResolvedPaths(candidates, 0, () => trySourceFiles(index + 1)); | ||
| 136 | }); | ||
| 137 | }; | ||
| 138 | |||
| 139 | const tryCandidates = (index: number): void => { | ||
| 140 | if (index >= FAVICON_CANDIDATES.length) { | ||
| 141 | trySourceFiles(0); | ||
| 142 | return; | ||
| 143 | } | ||
| 144 | const candidate = path.join(projectCwd, FAVICON_CANDIDATES[index]!); | ||
| 145 | if (!isPathWithinProject(projectCwd, candidate)) { | ||
| 146 | tryCandidates(index + 1); | ||
| 147 | return; | ||
| 148 | } | ||
| 149 | fs.stat(candidate, (err, stats) => { | ||
| 150 | if (err || !stats?.isFile()) { | ||
| 151 | tryCandidates(index + 1); | ||
| 152 | return; | ||
| 153 | } | ||
| 154 | serveFaviconFile(candidate, res); | ||
| 155 | }); | ||
| 156 | }; | ||
| 157 | |||
| 158 | tryCandidates(0); | ||
| 159 | return true; | ||
| 160 | } | ||
apps/server/src/wsServer.ts+2-153| ... | @@ -42,6 +42,7 @@ import { clamp } from "effect/Number"; | ... | @@ -42,6 +42,7 @@ import { clamp } from "effect/Number"; |
| 42 | import { Open } from "./open"; | 42 | import { Open } from "./open"; |
| 43 | import { ServerConfig } from "./config"; | 43 | import { ServerConfig } from "./config"; |
| 44 | import { GitCore } from "./git/Services/GitCore.ts"; | 44 | import { GitCore } from "./git/Services/GitCore.ts"; |
| 45 | import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute"; | ||
| 45 | 46 | ||
| 46 | const MIME_TYPES: Record<string, string> = { | 47 | const MIME_TYPES: Record<string, string> = { |
| 47 | ".html": "text/html; charset=utf-8", | 48 | ".html": "text/html; charset=utf-8", |
| ... | @@ -230,163 +231,11 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< | ... | @@ -230,163 +231,11 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 230 | }); | 231 | }); |
| 231 | }); | 232 | }); |
| 232 | 233 | ||
| 233 | // Well-known favicon paths checked in order. | ||
| 234 | const FAVICON_CANDIDATES = [ | ||
| 235 | "favicon.svg", | ||
| 236 | "favicon.ico", | ||
| 237 | "favicon.png", | ||
| 238 | "public/favicon.svg", | ||
| 239 | "public/favicon.ico", | ||
| 240 | "public/favicon.png", | ||
| 241 | "app/favicon.ico", | ||
| 242 | "app/favicon.png", | ||
| 243 | "app/icon.svg", | ||
| 244 | "app/icon.png", | ||
| 245 | "app/icon.ico", | ||
| 246 | "src/favicon.ico", | ||
| 247 | "src/favicon.svg", | ||
| 248 | "src/app/favicon.ico", | ||
| 249 | "src/app/icon.svg", | ||
| 250 | "src/app/icon.png", | ||
| 251 | ]; | ||
| 252 | |||
| 253 | // Files that may contain a <link rel="icon"> or icon metadata declaration. | ||
| 254 | const ICON_SOURCE_FILES = [ | ||
| 255 | "index.html", | ||
| 256 | "public/index.html", | ||
| 257 | "app/routes/__root.tsx", | ||
| 258 | "src/routes/__root.tsx", | ||
| 259 | "app/root.tsx", | ||
| 260 | "src/root.tsx", | ||
| 261 | "src/index.html", | ||
| 262 | ]; | ||
| 263 | |||
| 264 | // Extract icon href from HTML/TSX source content. | ||
| 265 | // Matches <link rel="icon" href="…"> or { rel: "icon", href: "…" } patterns. | ||
| 266 | const LINK_ICON_HTML_RE = /<link[^>]*rel=["'](?:icon|shortcut icon)["'][^>]*href=["']([^"'?]+)/i; | ||
| 267 | const LINK_ICON_OBJ_RE = /rel:\s*["'](?:icon|shortcut icon)["'][^}]*href:\s*["']([^"'?]+)/i; | ||
| 268 | function extractIconHref(source: string): string | null { | ||
| 269 | const htmlMatch = source.match(LINK_ICON_HTML_RE); | ||
| 270 | if (htmlMatch?.[1]) return htmlMatch[1]; | ||
| 271 | const objMatch = source.match(LINK_ICON_OBJ_RE); | ||
| 272 | if (objMatch?.[1]) return objMatch[1]; | ||
| 273 | return null; | ||
| 274 | } | ||
| 275 | |||
| 276 | /** | ||
| 277 | * Resolve an icon href (e.g. "/grass-logo.svg") to a file on disk. | ||
| 278 | * For href starting with "/" we look in public/ first, then project root. | ||
| 279 | */ | ||
| 280 | function resolveIconHref(projectCwd: string, href: string): string[] { | ||
| 281 | const clean = href.replace(/^\//, ""); | ||
| 282 | return [ | ||
| 283 | path.join(projectCwd, "public", clean), | ||
| 284 | path.join(projectCwd, clean), | ||
| 285 | ]; | ||
| 286 | } | ||
| 287 | |||
| 288 | function serveFaviconFile( | ||
| 289 | filePath: string, | ||
| 290 | res: http.ServerResponse, | ||
| 291 | ): void { | ||
| 292 | const ext = path.extname(filePath); | ||
| 293 | const contentType = MIME_TYPES[ext] ?? "application/octet-stream"; | ||
| 294 | fs.readFile(filePath, (readErr, data) => { | ||
| 295 | if (readErr) { | ||
| 296 | res.writeHead(500, { "Content-Type": "text/plain" }); | ||
| 297 | res.end("Read error"); | ||
| 298 | return; | ||
| 299 | } | ||
| 300 | res.writeHead(200, { | ||
| 301 | "Content-Type": contentType, | ||
| 302 | "Cache-Control": "public, max-age=3600", | ||
| 303 | }); | ||
| 304 | res.end(data); | ||
| 305 | }); | ||
| 306 | } | ||
| 307 | |||
| 308 | // HTTP server — serves static files or redirects to Vite dev server | 234 | // HTTP server — serves static files or redirects to Vite dev server |
| 309 | const httpServer = http.createServer((req, res) => { | 235 | const httpServer = http.createServer((req, res) => { |
| 310 | const url = new URL(req.url ?? "/", `http://localhost:${port}`); | 236 | const url = new URL(req.url ?? "/", `http://localhost:${port}`); |
| 311 | 237 | ||
| 312 | // Serve project favicons from their working directories. | 238 | if (tryHandleProjectFaviconRequest(url, res)) { |
| 313 | // Handled before the dev redirect so it works in both modes. | ||
| 314 | if (url.pathname === "/api/project-favicon") { | ||
| 315 | const projectCwd = url.searchParams.get("cwd"); | ||
| 316 | if (!projectCwd) { | ||
| 317 | res.writeHead(400, { "Content-Type": "text/plain" }); | ||
| 318 | res.end("Missing cwd parameter"); | ||
| 319 | return; | ||
| 320 | } | ||
| 321 | |||
| 322 | // Phase 1: Check well-known favicon paths. | ||
| 323 | const tryCandidates = (index: number) => { | ||
| 324 | if (index >= FAVICON_CANDIDATES.length) { | ||
| 325 | // Phase 2: Parse source files for icon link declarations. | ||
| 326 | trySourceFiles(0); | ||
| 327 | return; | ||
| 328 | } | ||
| 329 | const candidate = path.join(projectCwd, FAVICON_CANDIDATES[index]!); | ||
| 330 | if (!candidate.startsWith(projectCwd + path.sep)) { | ||
| 331 | tryCandidates(index + 1); | ||
| 332 | return; | ||
| 333 | } | ||
| 334 | fs.stat(candidate, (err, stats) => { | ||
| 335 | if (err || !stats?.isFile()) { | ||
| 336 | tryCandidates(index + 1); | ||
| 337 | return; | ||
| 338 | } | ||
| 339 | serveFaviconFile(candidate, res); | ||
| 340 | }); | ||
| 341 | }; | ||
| 342 | |||
| 343 | // Phase 2: Read source/HTML files and extract icon href references. | ||
| 344 | const trySourceFiles = (index: number) => { | ||
| 345 | if (index >= ICON_SOURCE_FILES.length) { | ||
| 346 | res.writeHead(404, { "Content-Type": "text/plain" }); | ||
| 347 | res.end("No favicon found"); | ||
| 348 | return; | ||
| 349 | } | ||
| 350 | const sourceFile = path.join(projectCwd, ICON_SOURCE_FILES[index]!); | ||
| 351 | fs.readFile(sourceFile, "utf8", (err, content) => { | ||
| 352 | if (err) { | ||
| 353 | trySourceFiles(index + 1); | ||
| 354 | return; | ||
| 355 | } | ||
| 356 | const href = extractIconHref(content); | ||
| 357 | if (!href) { | ||
| 358 | trySourceFiles(index + 1); | ||
| 359 | return; | ||
| 360 | } | ||
| 361 | const candidates = resolveIconHref(projectCwd, href); | ||
| 362 | tryResolvedPaths(candidates, 0, () => trySourceFiles(index + 1)); | ||
| 363 | }); | ||
| 364 | }; | ||
| 365 | |||
| 366 | const tryResolvedPaths = ( | ||
| 367 | paths: string[], | ||
| 368 | index: number, | ||
| 369 | onExhausted: () => void, | ||
| 370 | ) => { | ||
| 371 | if (index >= paths.length) { | ||
| 372 | onExhausted(); | ||
| 373 | return; | ||
| 374 | } | ||
| 375 | const candidate = paths[index]!; | ||
| 376 | if (!candidate.startsWith(projectCwd + path.sep)) { | ||
| 377 | tryResolvedPaths(paths, index + 1, onExhausted); | ||
| 378 | return; | ||
| 379 | } | ||
| 380 | fs.stat(candidate, (err, stats) => { | ||
| 381 | if (err || !stats?.isFile()) { | ||
| 382 | tryResolvedPaths(paths, index + 1, onExhausted); | ||
| 383 | return; | ||
| 384 | } | ||
| 385 | serveFaviconFile(candidate, res); | ||
| 386 | }); | ||
| 387 | }; | ||
| 388 | |||
| 389 | tryCandidates(0); | ||
| 390 | return; | 239 | return; |
| 391 | } | 240 | } |
| 392 | 241 |
apps/web/src/session-logic.test.ts+9| ... | @@ -169,6 +169,15 @@ describe("isLatestTurnSettled", () => { | ... | @@ -169,6 +169,15 @@ describe("isLatestTurnSettled", () => { |
| 169 | ).toBe(false); | 169 | ).toBe(false); |
| 170 | }); | 170 | }); |
| 171 | 171 | ||
| 172 | it("returns false while any turn is running to avoid stale latest-turn banners", () => { | ||
| 173 | expect( | ||
| 174 | isLatestTurnSettled(latestTurn, { | ||
| 175 | orchestrationStatus: "running", | ||
| 176 | activeTurnId: TurnId.makeUnsafe("turn-2"), | ||
| 177 | }), | ||
| 178 | ).toBe(false); | ||
| 179 | }); | ||
| 180 | |||
| 172 | it("returns true once the session is no longer running that turn", () => { | 181 | it("returns true once the session is no longer running that turn", () => { |
| 173 | expect( | 182 | expect( |
| 174 | isLatestTurnSettled(latestTurn, { | 183 | isLatestTurnSettled(latestTurn, { |
apps/web/src/session-logic.ts+2-3| ... | @@ -83,9 +83,8 @@ export function isLatestTurnSettled( | ... | @@ -83,9 +83,8 @@ export function isLatestTurnSettled( |
| 83 | if (!latestTurn?.startedAt) return false; | 83 | if (!latestTurn?.startedAt) return false; |
| 84 | if (!latestTurn.completedAt) return false; | 84 | if (!latestTurn.completedAt) return false; |
| 85 | if (!session) return true; | 85 | if (!session) return true; |
| 86 | return !( | 86 | if (session.orchestrationStatus === "running") return false; |
| 87 | session.orchestrationStatus === "running" && session.activeTurnId === latestTurn.turnId | 87 | return true; |
| 88 | ); | ||
| 89 | } | 88 | } |
| 90 | 89 | ||
| 91 | export function derivePendingApprovals( | 90 | export function derivePendingApprovals( |