History

fix: reconcile main's round-18 features after the rebase

Adopts the round's main features into the v2 architecture: the #9023 media rework (streamed videos, media-file assets, protocol-relative links), #9098 shared live-activity row folded into the v2 working and thinking rows, the #9084/#9078 Claude model catalog for v2 consumers, a native #9005 OpenCode child-session abort in the v2 adapter, #9013's landed LegendList patch, and per-environment sidebar provider entries. For #8600 the server-side pieces land, but auto-settle evaluation stays client-side (reading the new server-owned settings) until the v2 orchestrator grows its own settlement reactor; main's v1-only reactor and coalescer additions are dropped with the rest of the v1 path.

Julius Marminge committed 2 months ago

Commit 7b13bca1 parent 786a25847 files · +1855 −2616Browse files

Showing the changes to packages/client-runtime/src (5 of 47 files). Show all

Unified · Split

5 files
  1. packages/client-runtime/src/state/threadSettled.test.ts +587 −0
  2. packages/client-runtime/src/state/threadSettled.ts +0 −1
  3. packages/client-runtime/src/state/threads-sync.test.ts +6 −3
  4. packages/client-runtime/src/work-log/presentation.test.ts +1 −1
  5. packages/client-runtime/src/work-log/presentation.ts +2 −2
packages/client-runtime/src/state/threadSettled.test.ts+587−0
@@ -0,0 +1,587 @@
1import {
2 ProjectId,
3 ProviderInstanceId,
4 ThreadId,
5 TurnId,
6 type OrchestrationThreadShell,
7} from "@t3tools/contracts";
8import { describe, expect, it } from "vite-plus/test";
9
10import {
11 canSettle,
12 changeRequestAutoSettles,
13 effectiveSettled,
14 hasQueuedTurnStart,
15 threadLastActivityAt,
16 type ChangeRequestStateLike,
17} from "./threadSettled.ts";
18
19const NOW = "2026-04-10T00:00:00.000Z";
20const FRESH = "2026-04-09T00:00:00.000Z";
21const STALE = "2026-04-06T23:59:59.999Z";
22
23describe("changeRequestAutoSettles", () => {
24 it.each([
25 ["open", true, false],
26 ["merged", true, true],
27 ["merged", false, false],
28 ["closed", false, true],
29 [null, false, false],
30 ] as const)("state=%s autoSettleOnMerge=%s returns %s", (state, autoSettleOnMerge, expected) => {
31 expect(changeRequestAutoSettles(state === null ? null : { state }, { autoSettleOnMerge })).toBe(
32 expected,
33 );
34 });
35
36 const THREAD_CREATED_AT = "2026-04-01T00:00:00.000Z";
37 const idleThread = {
38 createdAt: THREAD_CREATED_AT,
39 latestUserMessageAt: null,
40 latestTurn: null,
41 };
42
43 it("ignores a terminal change request last touched before the thread existed", () => {
44 for (const state of ["merged", "closed"] as const) {
45 expect(
46 changeRequestAutoSettles(
47 { state, updatedAt: "2026-03-31T23:59:59.999Z" },
48 { thread: idleThread },
49 ),
50 ).toBe(false);
51 }
52 });
53
54 it("settles on a terminal change request touched at or after the thread's latest event", () => {
55 for (const updatedAt of [THREAD_CREATED_AT, "2026-04-02T00:00:00.000Z"]) {
56 expect(changeRequestAutoSettles({ state: "merged", updatedAt }, { thread: idleThread })).toBe(
57 true,
58 );
59 }
60 });
61
62 it("never re-settles a thread revived after the merge", () => {
63 // Settling on a merge happens once: a user message newer than the PR's
64 // last activity means the conversation outlived the PR.
65 const revived = {
66 createdAt: THREAD_CREATED_AT,
67 latestUserMessageAt: "2026-04-05T00:00:00.000Z",
68 latestTurn: null,
69 };
70 expect(
71 changeRequestAutoSettles(
72 { state: "merged", updatedAt: "2026-04-03T00:00:00.000Z" },
73 { thread: revived },
74 ),
75 ).toBe(false);
76 // A merge landing after the revival still settles.
77 expect(
78 changeRequestAutoSettles(
79 { state: "merged", updatedAt: "2026-04-06T00:00:00.000Z" },
80 { thread: revived },
81 ),
82 ).toBe(true);
83 });
84
85 it("still settles when the merge lands during an in-flight turn", () => {
86 // Anchor is user-initiated activity only: the agent finishing a turn
87 // after the merge must not block the settle the merge earned.
88 const midTurnMerge = {
89 createdAt: THREAD_CREATED_AT,
90 latestUserMessageAt: "2026-04-02T00:00:00.000Z",
91 latestTurn: {
92 turnId: TurnId.make("turn-mid"),
93 state: "completed" as const,
94 requestedAt: "2026-04-02T00:00:00.000Z",
95 startedAt: "2026-04-02T00:00:05.000Z",
96 completedAt: "2026-04-02T00:20:00.000Z",
97 assistantMessageId: null,
98 },
99 };
100 expect(
101 changeRequestAutoSettles(
102 { state: "merged", updatedAt: "2026-04-02T00:10:00.000Z" },
103 { thread: midTurnMerge },
104 ),
105 ).toBe(true);
106 });
107
108 it("falls back to settling when either timestamp is missing or malformed", () => {
109 expect(changeRequestAutoSettles({ state: "merged" }, { thread: idleThread })).toBe(true);
110 expect(
111 changeRequestAutoSettles({ state: "merged", updatedAt: null }, { thread: idleThread }),
112 ).toBe(true);
113 expect(
114 changeRequestAutoSettles({ state: "merged", updatedAt: "2026-03-01T00:00:00.000Z" }, {}),
115 ).toBe(true);
116 expect(
117 changeRequestAutoSettles(
118 { state: "merged", updatedAt: "not-a-date" },
119 { thread: idleThread },
120 ),
121 ).toBe(true);
122 });
123});
124
125function makeShell(input: {
126 readonly settledOverride?: "settled" | "active" | null;
127 readonly activityAt: string | null;
128 readonly sessionStatus?: "starting" | "running";
129 readonly pending?: "approval" | "user-input";
130}): OrchestrationThreadShell {
131 const threadId = ThreadId.make("thread-1");
132 return {
133 id: threadId,
134 projectId: ProjectId.make("project-1"),
135 title: "Thread",
136 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
137 runtimeMode: "full-access",
138 interactionMode: "default",
139 branch: null,
140 worktreePath: null,
141 latestTurn:
142 input.activityAt === null
143 ? null
144 : {
145 turnId: TurnId.make("turn-1"),
146 state: "completed",
147 requestedAt: input.activityAt,
148 startedAt: null,
149 completedAt: null,
150 assistantMessageId: null,
151 },
152 createdAt: "2026-04-01T00:00:00.000Z",
153 updatedAt: NOW,
154 archivedAt: null,
155 settledOverride: input.settledOverride ?? null,
156 settledAt: input.settledOverride === "settled" ? NOW : null,
157 session:
158 input.sessionStatus === undefined
159 ? null
160 : {
161 threadId,
162 status: input.sessionStatus,
163 providerName: "Codex",
164 runtimeMode: "full-access",
165 activeTurnId: null,
166 lastError: null,
167 updatedAt: NOW,
168 },
169 latestUserMessageAt: null,
170 hasPendingApprovals: input.pending === "approval",
171 hasPendingUserInput: input.pending === "user-input",
172 hasActionableProposedPlan: false,
173 };
174}
175
176describe("threadLastActivityAt", () => {
177 it("returns the latest real user or turn activity and ignores thread/session updates", () => {
178 const shell = makeShell({ activityAt: null, sessionStatus: "running" });
179 const withActivity: OrchestrationThreadShell = {
180 ...shell,
181 latestUserMessageAt: "2026-04-04T00:00:00.000Z",
182 latestTurn: {
183 turnId: TurnId.make("turn-1"),
184 state: "completed",
185 requestedAt: "2026-04-03T00:00:00.000Z",
186 startedAt: "2026-04-05T00:00:00.000Z",
187 completedAt: "2026-04-06T00:00:00.000Z",
188 assistantMessageId: null,
189 },
190 };
191
192 expect(threadLastActivityAt(withActivity)).toBe("2026-04-06T00:00:00.000Z");
193 expect(threadLastActivityAt(shell)).toBeNull();
194 });
195});
196
197describe("effectiveSettled", () => {
198 const overrideCases = [null, "settled", "active"] as const;
199 const changeRequestStates = [undefined, "open", "merged"] as const;
200 const inactivityCases = [
201 ["fresh", FRESH],
202 ["stale", STALE],
203 ["no-activity", null],
204 ] as const;
205 const runningCases = [false, true] as const;
206 const pendingCases = [undefined, "approval", "user-input"] as const;
207 const truthTable = overrideCases.flatMap((settledOverride) =>
208 changeRequestStates.flatMap((changeRequestState) =>
209 inactivityCases.flatMap(([inactivity, activityAt]) =>
210 runningCases.flatMap((running) =>
211 pendingCases.map((pending) => ({
212 settledOverride,
213 changeRequestState,
214 inactivity,
215 activityAt,
216 running,
217 pending,
218 // Settled iff nothing blocks (pending work / live session) AND
219 // the override says settled, or (with no override) a merged PR
220 // or staleness auto-settles. The "active" pin suppresses both
221 // auto signals, and an open PR suppresses the inactivity path:
222 // a thread with a PR out for review is never done, however quiet.
223 expected:
224 pending === undefined &&
225 !running &&
226 (settledOverride === "settled" ||
227 (settledOverride === null &&
228 (changeRequestState === "merged" ||
229 (changeRequestState !== "open" && inactivity === "stale")))),
230 })),
231 ),
232 ),
233 ),
234 );
235
236 it.each(truthTable)(
237 "override=$settledOverride pr=$changeRequestState inactivity=$inactivity running=$running pending=$pending",
238 ({ settledOverride, changeRequestState, activityAt, running, pending, expected }) => {
239 const shell = makeShell({
240 settledOverride,
241 activityAt,
242 ...(running ? { sessionStatus: "running" as const } : {}),
243 ...(pending === undefined ? {} : { pending }),
244 });
245 const changeRequestOptions =
246 changeRequestState === undefined
247 ? {}
248 : { changeRequest: { state: changeRequestState as ChangeRequestStateLike } };
249
250 expect(
251 effectiveSettled(shell, {
252 now: NOW,
253 autoSettleAfterDays: 3,
254 ...changeRequestOptions,
255 }),
256 ).toBe(expected);
257 },
258 );
259
260 it("treats closed change requests like merged ones", () => {
261 const shell = makeShell({ activityAt: null });
262 expect(
263 effectiveSettled(shell, {
264 now: NOW,
265 autoSettleAfterDays: null,
266 changeRequest: { state: "closed" },
267 }),
268 ).toBe(true);
269 });
270
271 it("settles immediately when a change request merges or closes", () => {
272 const recentlyActive = makeShell({ activityAt: "2026-04-09T23:59:59.999Z" });
273 for (const changeRequestState of ["merged", "closed"] as const) {
274 expect(
275 effectiveSettled(recentlyActive, {
276 now: NOW,
277 autoSettleAfterDays: null,
278 changeRequest: { state: changeRequestState },
279 }),
280 ).toBe(true);
281 }
282 });
283
284 it("ignores a change request that merged before the thread's latest event", () => {
285 // A new thread started at a worktree root inherits the branch's old
286 // merged PR, and a revived thread outlives its merge; neither settles
287 // the live conversation.
288 const fresh = makeShell({ activityAt: FRESH });
289 for (const state of ["merged", "closed"] as const) {
290 expect(
291 effectiveSettled(fresh, {
292 now: NOW,
293 autoSettleAfterDays: null,
294 changeRequest: { state, updatedAt: "2026-03-20T00:00:00.000Z" },
295 }),
296 ).toBe(false);
297 }
298 // A merge during the thread's life still settles it.
299 expect(
300 effectiveSettled(fresh, {
301 now: NOW,
302 autoSettleAfterDays: null,
303 changeRequest: { state: "merged", updatedAt: "2026-04-09T00:00:00.000Z" },
304 }),
305 ).toBe(true);
306 });
307
308 it("can keep a merged change request active", () => {
309 const recentlyActive = makeShell({ activityAt: "2026-04-09T23:59:59.999Z" });
310 expect(
311 effectiveSettled(recentlyActive, {
312 now: NOW,
313 autoSettleAfterDays: null,
314 autoSettleOnMerge: false,
315 changeRequest: { state: "merged" },
316 }),
317 ).toBe(false);
318
319 expect(
320 effectiveSettled(recentlyActive, {
321 now: NOW,
322 autoSettleAfterDays: null,
323 autoSettleOnMerge: false,
324 changeRequest: { state: "closed" },
325 }),
326 ).toBe(true);
327 });
328
329 it("never auto-settles a stale thread with an open change request", () => {
330 const stale = makeShell({ activityAt: STALE });
331 expect(
332 effectiveSettled(stale, {
333 now: NOW,
334 autoSettleAfterDays: 3,
335 changeRequest: { state: "open" },
336 }),
337 ).toBe(false);
338 // An explicit user settle still wins: open PR only blocks the auto path.
339 const settled = makeShell({ settledOverride: "settled", activityAt: STALE });
340 expect(
341 effectiveSettled(settled, {
342 now: NOW,
343 autoSettleAfterDays: 3,
344 changeRequest: { state: "open" },
345 }),
346 ).toBe(true);
347 });
348
349 it("keeps an explicitly un-settled merged-PR thread active", () => {
350 const shell = makeShell({
351 settledOverride: "active",
352 activityAt: "2026-04-09T23:59:59.999Z",
353 });
354 expect(
355 effectiveSettled(shell, {
356 now: NOW,
357 autoSettleAfterDays: null,
358 changeRequest: { state: "merged" },
359 }),
360 ).toBe(false);
361 });
362
363 it("never settles a starting session, even with a settled override", () => {
364 const shell = makeShell({
365 settledOverride: "settled",
366 activityAt: STALE,
367 sessionStatus: "starting",
368 });
369 expect(
370 effectiveSettled(shell, {
371 now: NOW,
372 autoSettleAfterDays: 3,
373 changeRequest: { state: "merged" },
374 }),
375 ).toBe(false);
376 });
377
378 it("keeps a new turn active from queued through starting and running", () => {
379 const requestedAt = "2026-04-09T12:00:00.000Z";
380 const transitionNow = "2026-04-09T12:00:30.000Z";
381 const base = makeShell({
382 settledOverride: null,
383 activityAt: STALE,
384 });
385 const queued: OrchestrationThreadShell = {
386 ...base,
387 latestUserMessageAt: requestedAt,
388 latestTurn: null,
389 session: null,
390 };
391 const starting: OrchestrationThreadShell = {
392 ...queued,
393 session: {
394 threadId: queued.id,
395 status: "starting",
396 providerName: "Codex",
397 runtimeMode: "full-access",
398 activeTurnId: null,
399 lastError: null,
400 updatedAt: requestedAt,
401 },
402 };
403 const running: OrchestrationThreadShell = {
404 ...starting,
405 session: {
406 ...starting.session!,
407 status: "running",
408 activeTurnId: TurnId.make("turn-new"),
409 },
410 };
411
412 for (const shell of [queued, starting, running]) {
413 expect(
414 effectiveSettled(shell, {
415 now: transitionNow,
416 autoSettleAfterDays: 3,
417 changeRequest: { state: "merged" },
418 }),
419 ).toBe(false);
420 }
421 });
422
423 it("uses a strict inactivity boundary and honors a null threshold", () => {
424 const boundary = makeShell({
425 activityAt: "2026-04-07T00:00:00.000Z",
426 });
427 const stale = makeShell({ activityAt: STALE });
428
429 expect(effectiveSettled(boundary, { now: NOW, autoSettleAfterDays: 3 })).toBe(false);
430 expect(effectiveSettled(stale, { now: NOW, autoSettleAfterDays: null })).toBe(false);
431 });
432});
433
434describe("hasQueuedTurnStart", () => {
435 const QUEUED_AT = "2026-04-09T12:00:00.000Z";
436 // Within the adoption grace window of the queued message.
437 const JUST_AFTER = { now: "2026-04-09T12:00:30.000Z" };
438
439 it("flags a user message no turn has picked up, within the grace window", () => {
440 const noTurn = { latestUserMessageAt: QUEUED_AT, latestTurn: null, session: null };
441 expect(hasQueuedTurnStart(noTurn, JUST_AFTER)).toBe(true);
442
443 const staleTurn = {
444 ...makeShell({ activityAt: FRESH }),
445 latestUserMessageAt: QUEUED_AT,
446 };
447 expect(hasQueuedTurnStart(staleTurn, JUST_AFTER)).toBe(true);
448 });
449
450 it("expires after the grace window: an unadopted message is a failed start, not queued work", () => {
451 const noTurn = { latestUserMessageAt: QUEUED_AT, latestTurn: null, session: null };
452 expect(hasQueuedTurnStart(noTurn, { now: "2026-04-09T12:03:00.000Z" })).toBe(false);
453 // Historical shells (e.g. from servers that never carried latestTurn)
454 // must never read as queued.
455 expect(hasQueuedTurnStart(noTurn, { now: NOW })).toBe(false);
456 });
457
458 it("clears once a turn adopts the message or the start fails", () => {
459 const adopted = {
460 ...makeShell({ activityAt: QUEUED_AT }),
461 latestUserMessageAt: QUEUED_AT,
462 };
463 expect(hasQueuedTurnStart(adopted, JUST_AFTER)).toBe(false);
464
465 const failed = makeShell({ activityAt: FRESH });
466 const failedShell = {
467 ...failed,
468 latestUserMessageAt: QUEUED_AT,
469 session: {
470 threadId: failed.id,
471 status: "error" as const,
472 providerName: "Codex",
473 runtimeMode: "full-access" as const,
474 activeTurnId: null,
475 lastError: "boom",
476 updatedAt: NOW,
477 },
478 };
479 expect(hasQueuedTurnStart(failedShell, JUST_AFTER)).toBe(false);
480 });
481
482 it("is quiet without user messages", () => {
483 expect(hasQueuedTurnStart(makeShell({ activityAt: FRESH }), JUST_AFTER)).toBe(false);
484 });
485
486 it("bounds the grace window in both directions: a future-stamped message is skew, not queued work", () => {
487 // Message timestamps originate on other devices; a clock an hour ahead
488 // must not hold the queued state for the whole skew.
489 const skewed = {
490 latestUserMessageAt: "2026-04-09T13:00:00.000Z",
491 latestTurn: null,
492 session: null,
493 };
494 expect(hasQueuedTurnStart(skewed, { now: "2026-04-09T12:00:00.000Z" })).toBe(false);
495 // A small negative age (within the grace window) still reads as queued.
496 const slightlyAhead = {
497 latestUserMessageAt: "2026-04-09T12:00:30.000Z",
498 latestTurn: null,
499 session: null,
500 };
501 expect(hasQueuedTurnStart(slightlyAhead, { now: "2026-04-09T12:00:00.000Z" })).toBe(true);
502 });
503});
504
505describe("canSettle", () => {
506 it("blocks every state effectiveSettled refuses to classify as settled", () => {
507 expect(canSettle(makeShell({ activityAt: FRESH }), { now: NOW })).toBe(true);
508 expect(
509 canSettle(makeShell({ activityAt: FRESH, sessionStatus: "starting" }), { now: NOW }),
510 ).toBe(false);
511 expect(
512 canSettle(makeShell({ activityAt: FRESH, sessionStatus: "running" }), { now: NOW }),
513 ).toBe(false);
514 expect(canSettle(makeShell({ activityAt: FRESH, pending: "approval" }), { now: NOW })).toBe(
515 false,
516 );
517 expect(canSettle(makeShell({ activityAt: FRESH, pending: "user-input" }), { now: NOW })).toBe(
518 false,
519 );
520 });
521
522 it("blocks settling a queued turn start, only within the grace window", () => {
523 const queued = {
524 ...makeShell({ activityAt: FRESH }),
525 latestUserMessageAt: "2026-04-09T12:00:00.000Z",
526 };
527 const justAfter = "2026-04-09T12:00:30.000Z";
528 expect(canSettle(queued, { now: justAfter })).toBe(false);
529 // effectiveSettled must agree: queued work never auto-settles either,
530 // even with a merged PR.
531 expect(
532 effectiveSettled(queued, {
533 now: justAfter,
534 autoSettleAfterDays: 3,
535 changeRequest: { state: "merged" },
536 }),
537 ).toBe(false);
538 // Past the window the message is a failed/stale start: settleable again.
539 expect(canSettle(queued, { now: NOW })).toBe(true);
540 });
541
542 it("lets a server-accepted settle overrule the clock-derived queued blocker", () => {
543 // The settle action ran with wall-clock `now` (past the grace window);
544 // the list partition re-evaluates with a minute-floored `now` that is
545 // still INSIDE the window. settledAt >= message time proves the server
546 // already adjudicated this exact message, so the row must not snap back
547 // to active until the coarser clock catches up.
548 const messageAt = "2026-04-09T12:00:00.000Z";
549 const flooredNow = "2026-04-09T12:01:00.000Z";
550 const base = makeShell({ settledOverride: "settled", activityAt: null });
551 const settledAfterMessage = {
552 ...base,
553 latestUserMessageAt: messageAt,
554 settledAt: "2026-04-09T12:02:10.000Z",
555 };
556 expect(hasQueuedTurnStart(settledAfterMessage, { now: flooredNow })).toBe(true);
557 expect(effectiveSettled(settledAfterMessage, { now: flooredNow, autoSettleAfterDays: 3 })).toBe(
558 true,
559 );
560
561 // A message NEWER than settledAt is genuinely new work: still blocked
562 // until the server's auto-unsettle lands.
563 const messageAfterSettle = {
564 ...base,
565 latestUserMessageAt: "2026-04-09T12:03:00.000Z",
566 settledAt: "2026-04-09T12:02:10.000Z",
567 };
568 expect(
569 effectiveSettled(messageAfterSettle, {
570 now: "2026-04-09T12:03:30.000Z",
571 autoSettleAfterDays: 3,
572 }),
573 ).toBe(false);
574 });
575
576 it("agrees with effectiveSettled's blockers for explicitly settled shells", () => {
577 // Anything canSettle rejects must render as active even when the user
578 // settled it earlier.
579 const blocked = makeShell({
580 settledOverride: "settled",
581 activityAt: FRESH,
582 pending: "user-input",
583 });
584 expect(canSettle(blocked, { now: NOW })).toBe(false);
585 expect(effectiveSettled(blocked, { now: NOW, autoSettleAfterDays: 3 })).toBe(false);
586 });
587});
packages/client-runtime/src/state/threadSettled.ts+0−1
@@ -138,7 +138,6 @@ export function threadLastActivityAt(shell: SettlementThreadShell): string | nul · 137 unmodified lines
138 * such threads would be permanently unsettleable.138 * such threads would be permanently unsettleable.
139 */139 */
140export const QUEUED_TURN_START_GRACE_MS = 2 * 60 * 1_000;140export const QUEUED_TURN_START_GRACE_MS = 2 * 60 * 1_000;
141const DAY_MS = 24 * 60 * 60 * 1_000;
142 141
143/**142/**
144 * A user message no turn has picked up yet: the turn.start command was143 * A user message no turn has picked up yet: the turn.start command was
packages/client-runtime/src/state/threads-sync.test.ts+6−3
@@ -70,9 +70,12 @@ function testSession( · 69 unmodified lines
70): RpcSession.RpcSession {70): RpcSession.RpcSession {
71 return {71 return {
72 client,72 client,
73 initialConfig: Effect.succeed({73 initialConfig: Effect.succeed(
74 threadResumeCompletionMarker: config?.completionMarker === true,74 config?.completionMarker === true
75 } as never),75 ? ({ threadResumeCompletionMarker: true } as never)
76 : ({} as never),
77 ),
78 subscribeServerConfig: (input) => client.subscribeServerConfig(input),
76 ready: Effect.void,79 ready: Effect.void,
77 probe: Effect.void,80 probe: Effect.void,
78 closed: Effect.never,81 closed: Effect.never,
packages/client-runtime/src/work-log/presentation.test.ts+1−1
@@ -444,7 +444,7 @@ describe("resolveViewedImageAsset", () => { · 443 unmodified lines
444 }),444 }),
445 ).toEqual({445 ).toEqual({
446 resource: {446 resource: {
447 _tag: "workspace-file",447 _tag: "media-file",
448 threadId,448 threadId,
449 path: "/workspace/screens/logo.svg",449 path: "/workspace/screens/logo.svg",
450 },450 },
packages/client-runtime/src/work-log/presentation.ts+2−2
@@ -135,7 +135,7 @@ export function workEntryViewedImagePath(entry: WorkLogPresentationEntry): strin · 134 unmodified lines
135}135}
136 136
137export interface ViewedImageAsset {137export interface ViewedImageAsset {
138 readonly resource: Extract<AssetResource, { readonly _tag: "attachment" | "workspace-file" }>;138 readonly resource: Extract<AssetResource, { readonly _tag: "attachment" | "media-file" }>;
139 readonly alt: string;139 readonly alt: string;
140 readonly srcFragment: string;140 readonly srcFragment: string;
141}141}
@@ -165,7 +165,7 @@ export function resolveViewedImageAsset( · 23 unmodified lines
165 return {165 return {
166 resource: attachmentId166 resource: attachmentId
167 ? { _tag: "attachment", attachmentId }167 ? { _tag: "attachment", attachmentId }
168 : { _tag: "workspace-file", threadId: input.threadId, path },168 : { _tag: "media-file", threadId: input.threadId, path },
169 alt: path.split(/[\\/]/).at(-1) ?? "image",169 alt: path.split(/[\\/]/).at(-1) ?? "image",
170 srcFragment: markdownImageSourceFragment(source),170 srcFragment: markdownImageSourceFragment(source),
171 };171 };