From 65541d20861cae4da042c68aec1583236c77aa18 Mon Sep 17 00:00:00 2001 From: Kaloyan Stoyanov Date: Sat, 3 Oct 2026 18:50:13 +0300 Subject: [PATCH 01/10] chore(web): record React update loop audit From 8e0ce84ce955966dc09e315e833a1a0dcfdb1fc6 Mon Sep 17 00:00:00 2001 From: Kaloyan Stoyanov Date: Sat, 3 Oct 2026 19:00:40 +0300 Subject: [PATCH 02/10] fix(web): stop thread details layout update loops --- .../src/components/chat/ChatCanvas.test.tsx | 397 ++++++++++++++++++ apps/web/src/components/chat/ChatCanvas.tsx | 30 +- .../src/components/chat/ChatCanvasContext.ts | 1 + .../src/components/chat/ThreadDetailsCard.tsx | 8 +- 4 files changed, 424 insertions(+), 12 deletions(-) create mode 100644 apps/web/src/components/chat/ChatCanvas.test.tsx diff --git a/apps/web/src/components/chat/ChatCanvas.test.tsx b/apps/web/src/components/chat/ChatCanvas.test.tsx new file mode 100644 index 0000000000000..e7f20ce0efe5e --- /dev/null +++ b/apps/web/src/components/chat/ChatCanvas.test.tsx @@ -0,0 +1,397 @@ +// @vitest-environment jsdom +import { + EnvironmentId, + NodeId, + ProviderDriverKind, + RunId, + ThreadId, + type OrchestrationV2ThreadProjection, + type ScopedThreadRef, + type ThreadPullRequestLink, +} from "@t3tools/contracts"; +import { act, StrictMode, useLayoutEffect } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; + +import { useRightPanelStore } from "../../rightPanelStore"; +import { makeThreadProjectionFixture } from "../../test-fixtures"; +import { PopoverCreateHandle } from "../ui/popover"; +import { ChatCanvas } from "./ChatCanvas"; +import { useChatCanvas } from "./ChatCanvasContext"; +import type { ChatCanvasPreview } from "./chatCanvasLayout"; +import { ThreadDetailsCard } from "./ThreadDetailsCard"; +import { ThreadDetailsPanel, type ThreadDetailsPanelProps } from "./ThreadDetailsPanel"; + +const snapshots = vi.hoisted(() => ({ + projection: null as OrchestrationV2ThreadProjection | null, + pullRequests: [] as ThreadPullRequestLink[], + command: vi.fn(), +})); +vi.mock("@tanstack/react-router", () => ({ useNavigate: () => snapshots.command })); +vi.mock("../../state/entities", () => ({ + useThreadProjection: () => ({ projection: snapshots.projection }), + useThreadShells: () => [], + useProjects: () => [], + useServerConfigs: () => new Map(), +})); +vi.mock("../../lib/archivedThreadsState", () => ({ + useArchivedThreadSnapshots: () => ({ snapshots: [] }), +})); +vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => snapshots.command })); +vi.mock("../../hooks/useT3ProjectFileScripts", () => ({ useT3ProjectFileScripts: () => [] })); +vi.mock("../ProjectScriptsControl", () => ({ default: () => null })); +vi.mock("../GitActionsControl", () => ({ default: () => null })); +vi.mock("./ThreadAutomationsPanel", () => ({ ThreadAutomationsPanel: () => null })); +vi.mock("./ThreadDetailsPrRow", () => ({ + ThreadDetailsPrRow: ({ number, label }: { number: number; label: string }) => ( +
{label}
+ ), +})); +vi.mock("../BranchToolbar", async () => { + const { ThreadDetailsPrRows } = await import("./ThreadDetailsPrRows"); + return { + BranchToolbar: ({ panelSection }: { panelSection: string }) => { + const link = snapshots.pullRequests[0]; + return panelSection === "branch" && link ? ( + + ) : null; + }, + }; +}); + +const threadRef = { + environmentId: EnvironmentId.make("canvas-test"), + threadId: ThreadId.make("parent"), +}; +const preview: ChatCanvasPreview = { + key: "browser:test", + width: 267, + position: null, + source: { width: 1600, height: 1000 }, +}; +const canvasChanges = vi.fn<(canvas: ReturnType) => void>(); +const getAnimationsDescriptor = Object.getOwnPropertyDescriptor(Element.prototype, "getAnimations"); +let dimensions: { width: number; height: number }; +let contentHeights: { full: number; compact: number; essential: number }; +let root: Root; +let container: HTMLDivElement; +let composer: HTMLDivElement; +let anchor: HTMLButtonElement; +let handle: ReturnType; +let errors: unknown[]; +const observers = new Map< + ResizeObserver, + { callback: ResizeObserverCallback; targets: Set } +>(); + +function LayoutProbe() { + const canvas = useChatCanvas(); + useLayoutEffect(() => { + canvasChanges(canvas); + }, [canvas]); + return null; +} + +function FloatingPreview({ input }: { input: ChatCanvasPreview }) { + const canvas = useChatCanvas(); + const report = canvas?.reportPreview; + const clear = canvas?.clearPreview; + useLayoutEffect(() => report?.(input), [report, input]); + useLayoutEffect(() => () => clear?.(input.key), [clear, input.key]); + return null; +} + +beforeEach(() => { + dimensions = { width: 1266, height: 499 }; + contentHeights = { full: 300, compact: 180, essential: 80 }; + errors = []; + snapshots.projection = makeThreadProjectionFixture(); + snapshots.pullRequests = []; + canvasChanges.mockClear(); + observers.clear(); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + Object.defineProperty(Element.prototype, "getAnimations", { + configurable: true, + value: () => [], + }); + vi.stubGlobal( + "ResizeObserver", + class implements ResizeObserver { + constructor(callback: ResizeObserverCallback) { + observers.set(this, { callback, targets: new Set() }); + } + observe(target: Element) { + observers.get(this)?.targets.add(target); + } + unobserve(target: Element) { + observers.get(this)?.targets.delete(target); + } + disconnect() { + observers.delete(this); + } + }, + ); + const computedStyle = getComputedStyle; + const probeStyles = document.createElement("div").style; + probeStyles.width = "736px"; + probeStyles.minWidth = "640px"; + probeStyles.paddingLeft = "20px"; + vi.spyOn(window, "getComputedStyle").mockImplementation((element, pseudo) => { + if ( + element.parentElement?.hasAttribute("data-chat-canvas") && + element.hasAttribute("aria-hidden") + ) { + return probeStyles; + } + return computedStyle(element, pseudo); + }); + vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockImplementation(function ( + this: HTMLElement, + ) { + return this.hasAttribute("data-chat-canvas") ? dimensions.width : 0; + }); + vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockImplementation(function ( + this: HTMLElement, + ) { + return this.hasAttribute("data-chat-canvas") ? dimensions.height : 0; + }); + vi.spyOn(HTMLElement.prototype, "offsetHeight", "get").mockImplementation(function ( + this: HTMLElement, + ) { + if ( + this.firstElementChild?.matches( + "section[aria-labelledby=thread-details-workspace-heading],section[aria-label=Workspace]", + ) + ) { + return ( + 160 + + this.querySelectorAll("li").length * 36 + + this.querySelectorAll("[data-pr-row]").length * 36 + ); + } + if (!this.firstElementChild?.hasAttribute("data-card-body")) return 0; + const density = this.closest("[data-density]")?.getAttribute("data-density"); + return density === "compact" + ? contentHeights.compact + : density === "essential" + ? contentHeights.essential + : contentHeights.full; + }); + useRightPanelStore.setState({ threadPanelVisibilityByThreadKey: {}, byThreadKey: {} }); + container = document.createElement("div"); + composer = document.createElement("div"); + anchor = document.createElement("button"); + document.body.append(container, composer, anchor); + vi.spyOn(composer, "getBoundingClientRect").mockReturnValue(new DOMRect(0, 0, 736, 275)); + handle = PopoverCreateHandle(); + root = createRoot(container, { onUncaughtError: (error) => errors.push(error) }); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + composer.remove(); + anchor.remove(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + if (getAnimationsDescriptor) + Object.defineProperty(Element.prototype, "getAnimations", getAnimationsDescriptor); + else Reflect.deleteProperty(Element.prototype, "getAnimations"); +}); + +async function render(input: ChatCanvasPreview | null = null, ref: ScopedThreadRef = threadRef) { + await act(async () => + root.render( + + + + {input ? : null} + {}} + > + {(density) =>
{density}
} +
+
+
, + ), + ); +} + +async function resize() { + await act(async () => { + const pendingObservers = [...observers]; + for (const [observer, { callback }] of pendingObservers) callback([], observer); + }); +} + +it("keeps the chat canvas unchanged when details resize without a floating preview", async () => { + await render(); + const changes = canvasChanges.mock.calls.length; + contentHeights.full = 350; + await resize(); + expect(errors).toEqual([]); + expect(container.querySelector("[data-density]")?.getAttribute("data-density")).toBe("full"); + expect(canvasChanges).toHaveBeenCalledTimes(changes); +}); + +it("settles card and preview placement instead of exceeding React's update limit", async () => { + contentHeights.full = 820; + await render(preview); + expect(errors).toEqual([]); + const card = container.querySelector("[data-thread-details-panel=inline]"); + expect(card).not.toBeNull(); + expect(card?.style.width).toBe("267px"); + const changes = canvasChanges.mock.calls.length; + await resize(); + expect(canvasChanges).toHaveBeenCalledTimes(changes); +}); + +it("restores full details after space returns and survives closing and switching threads", async () => { + await render(); + dimensions.height = 250; + await resize(); + expect(container.querySelector("[data-density]")?.getAttribute("data-density")).toBe("compact"); + dimensions.height = 499; + await resize(); + expect(container.querySelector("[data-density]")?.getAttribute("data-density")).toBe("full"); + await act(async () => + useRightPanelStore.getState().setThreadPanelOpen(threadRef, "inline", false), + ); + expect(container.querySelector("[data-thread-details-panel=inline]")).toBeNull(); + await act(async () => + useRightPanelStore.getState().setThreadPanelOpen(threadRef, "inline", true), + ); + await render(null, { ...threadRef, threadId: ThreadId.make("child") }); + expect(container.querySelector("[data-density]")?.getAttribute("data-density")).toBe("full"); + expect(errors).toEqual([]); +}); + +it("releases preview bounds when closed and restores the card after a docked panel closes", async () => { + await render(preview); + await render(); + expect(canvasChanges.mock.lastCall?.[0]?.layout.frame).toBeNull(); + dimensions.width = 900; + await resize(); + expect(container.querySelector("[data-thread-details-panel=inline]")).toBeNull(); + dimensions.width = 1266; + await resize(); + expect(container.querySelector("[data-density]")?.getAttribute("data-density")).toBe("full"); + await render(preview); + expect( + container.querySelector("[data-thread-details-panel=inline]")?.style.width, + ).toBe("267px"); + expect(errors).toEqual([]); +}); + +it("renders agent and PR updates through the details panel without a floating preview", async () => { + dimensions.height = 900; + const projection = snapshots.projection!; + const props: ThreadDetailsPanelProps = { + environmentId: threadRef.environmentId, + threadId: projection.thread.id, + anchor: { current: anchor }, + handle, + onPresentationChange: snapshots.command, + activeProjectName: "Project", + activeProjectScripts: [], + preferredScriptId: null, + keybindings: [], + availableEditors: [], + showOpenInPicker: false, + gitCwd: "/workspace", + isGitRepo: true, + envLocked: false, + availableEnvironments: [], + onEnvironmentChange: snapshots.command, + onEnvModeChange: snapshots.command, + envMode: "local", + startFromOrigin: false, + onStartFromOriginChange: snapshots.command, + onComposerFocusRequest: snapshots.command, + versionMismatch: null, + onDismissVersionMismatch: snapshots.command, + onRunProjectScript: snapshots.command, + onAddProjectScript: snapshots.command, + onUpdateProjectScript: snapshots.command, + onDeleteProjectScript: snapshots.command, + }; + const update = async () => { + await act(async () => + root.render( + + + + + + , + ), + ); + const changes = canvasChanges.mock.calls.length; + await resize(); + expect(errors).toEqual([]); + expect(canvasChanges).toHaveBeenCalledTimes(changes); + }; + await update(); + for (const status of ["pending", "running", "waiting", "completed", "failed"] as const) { + snapshots.projection = { + ...projection, + subagents: Array.from({ length: 8 }, (_, index) => ({ + id: NodeId.make(`agent-${index}`), + threadId: projection.thread.id, + runId: RunId.make("run"), + parentNodeId: NodeId.make("parent-node"), + origin: "app_owned" as const, + createdBy: "agent" as const, + driver: ProviderDriverKind.make("codex"), + providerInstanceId: projection.thread.providerInstanceId, + providerThreadId: null, + childThreadId: ThreadId.make(`child-${index}`), + nativeTaskRef: null, + prompt: "Check the change", + title: `Worker ${index}`, + model: "gpt-5.4", + status, + result: null, + startedAt: null, + completedAt: null, + updatedAt: projection.updatedAt, + })), + }; + snapshots.pullRequests = [1, 2].map((number) => ({ + host: "github.com", + repository: "acme/project", + number, + url: `https://github.com/acme/project/pull/${number}`, + source: "agent", + linkedAt: "2026-10-03T14:43:00Z", + snapshot: null, + stack: null, + })); + await update(); + expect(container.textContent).toContain("#1"); + expect(container.textContent).toContain("Show 1 more"); + expect(container.textContent).toContain( + status === "completed" || status === "failed" ? "Previous agents" : "Worker 0", + ); + } + snapshots.projection = projection; + snapshots.pullRequests = []; + await update(); + expect(container.textContent).not.toContain("Previous agents"); + expect(container.textContent).not.toContain("#1"); +}); diff --git a/apps/web/src/components/chat/ChatCanvas.tsx b/apps/web/src/components/chat/ChatCanvas.tsx index bc222d07852e6..a2d7e2e048018 100644 --- a/apps/web/src/components/chat/ChatCanvas.tsx +++ b/apps/web/src/components/chat/ChatCanvas.tsx @@ -24,15 +24,20 @@ export function ChatCanvas({ const [timelineElement, registerTimeline] = useState(null); const [preview, setPreview] = useState(null); const [detailsCard, setDetailsCard] = useState(null); - const reportDetailsCard = useCallback((next: PreviewMiniPlayerObstacles["detailsCard"]) => { - setDetailsCard((current) => - current?.left === next?.left && - current?.right === next?.right && - current?.bottom === next?.bottom - ? current - : next, - ); - }, []); + const hasPreview = preview !== null; + const reportDetailsCard = useCallback( + (next: PreviewMiniPlayerObstacles["detailsCard"]) => { + if (!hasPreview) return; + setDetailsCard((current) => + current?.left === next?.left && + current?.right === next?.right && + current?.bottom === next?.bottom + ? current + : next, + ); + }, + [hasPreview], + ); const [measurements, setMeasurements] = useState({ width: 0, height: 0, @@ -94,9 +99,14 @@ export function ChatCanvas({ }, [composerOverlayElement, timelineElement]); const context = useMemo(() => { const container = { width: measurements.width, height: measurements.height }; + const preferredLayout = resolveChatCanvasLayout({ ...measurements, container, preview }); return { container, - layout: resolveChatCanvasLayout({ ...measurements, container, preview, detailsCard }), + layout: detailsCard + ? resolveChatCanvasLayout({ ...measurements, container, preview, detailsCard }) + : preferredLayout, + // Card avoidance must not change the width the card measures and reports. + preferredChat: preferredLayout.chat, previewKey: preview?.key ?? null, reportPreview, clearPreview, diff --git a/apps/web/src/components/chat/ChatCanvasContext.ts b/apps/web/src/components/chat/ChatCanvasContext.ts index b810b02392405..9447c0caf2e90 100644 --- a/apps/web/src/components/chat/ChatCanvasContext.ts +++ b/apps/web/src/components/chat/ChatCanvasContext.ts @@ -5,6 +5,7 @@ import type { PreviewMiniPlayerObstacles } from "../preview/previewMiniPlayerLay export const ChatCanvasContext = createContext<{ container: { width: number; height: number }; layout: ReturnType; + preferredChat: ReturnType["chat"]; previewKey: string | null; reportPreview: (preview: ChatCanvasPreview) => void; clearPreview: (key: string) => void; diff --git a/apps/web/src/components/chat/ThreadDetailsCard.tsx b/apps/web/src/components/chat/ThreadDetailsCard.tsx index 1886aa1ec483c..6d1b5e744e90f 100644 --- a/apps/web/src/components/chat/ThreadDetailsCard.tsx +++ b/apps/web/src/components/chat/ThreadDetailsCard.tsx @@ -29,12 +29,16 @@ export function ThreadDetailsCard({ const preferredPlacement = canvas ? resolveThreadDetailsCardLayout({ container: canvas.container, - chat: canvas.layout.chat, + chat: canvas.preferredChat, frame: null, }) : null; const placement = canvas - ? resolveThreadDetailsCardLayout({ container: canvas.container, ...canvas.layout }) + ? resolveThreadDetailsCardLayout({ + container: canvas.container, + ...canvas.layout, + chat: canvas.preferredChat, + }) : null; const mode = placement ? "inline" : "popover"; const inlineOpen = useRightPanelStore((state) => From 4d58b10890311ad5e15075e75a50f42efd2b3aa1 Mon Sep 17 00:00:00 2001 From: Kaloyan Stoyanov Date: Sat, 3 Oct 2026 19:58:27 +0300 Subject: [PATCH 03/10] fix(server): linked issue chips show when an issue is closed Record the last observed issue state on projection_issue_links, refresh it when a linked worktree's pull requests merge, and tint closed issue chips in the sidebar. --- .../src/issue/IssueCompletionReactor.test.ts | 26 ++++++++++-- .../src/issue/IssueCompletionReactor.ts | 42 ++++++++++++++++++- apps/server/src/issue/IssueService.ts | 19 +++++++++ .../src/persistence/ForkSchema/IssueLinks.ts | 5 +++ .../src/project/ProjectWorktreeLinks.ts | 5 ++- apps/web/src/components/Sidebar.tsx | 18 ++++++-- packages/contracts/src/issue.ts | 2 + 7 files changed, 109 insertions(+), 8 deletions(-) diff --git a/apps/server/src/issue/IssueCompletionReactor.test.ts b/apps/server/src/issue/IssueCompletionReactor.test.ts index c81c502f02fb9..f40b4ed987584 100644 --- a/apps/server/src/issue/IssueCompletionReactor.test.ts +++ b/apps/server/src/issue/IssueCompletionReactor.test.ts @@ -105,12 +105,17 @@ function runCase(input: { readonly repeat?: boolean; readonly noIssue?: boolean; readonly otherPullRequestState?: "open" | "closed" | "merged"; + readonly linkState?: "open" | "closed"; }) { return Effect.scoped( Effect.gen(function* () { const current = links(input.pullRequestState); const projectLinks: ProjectWorktreeLinks.ProjectWorktreeLinkSet = { - worktreeIssues: input.noIssue ? [] : current.worktreeIssues, + worktreeIssues: input.noIssue + ? [] + : current.worktreeIssues.map((link) => + input.linkState === undefined ? link : { ...link, state: input.linkState }, + ), worktreePullRequests: input.otherPullRequestState ? [ ...current.worktreePullRequests, @@ -128,6 +133,7 @@ function runCase(input: { const closes: IssueCloseInput[] = []; const comments: IssueCommentCreateInput[] = []; const calls: string[] = []; + let detailReads = 0; const savedComments: IssueComment[] = input.existingComment ? [{ id: "existing", author: null, body: COMMENT_BODY, createdAt: NOW, url: null }] : []; @@ -148,7 +154,7 @@ function runCase(input: { }), ServerSettingsService.layerTest({ completeLinkedIssueOnMerge: input.enabled }), Layer.mock(IssueService)({ - detail: () => Effect.succeed(currentDetail), + detail: () => Effect.sync(() => (detailReads += 1)).pipe(Effect.as(currentDetail)), invalidate: () => Effect.void, close: (closeInput) => Effect.gen(function* () { @@ -206,7 +212,7 @@ function runCase(input: { const cleanupAllowed = input.repeat ? yield* reactor.completeWorktree(PROJECT_ID, WORKTREE_PATH) : undefined; - return { closes, comments, calls, cleanupAllowed }; + return { closes, comments, calls, cleanupAllowed, detailReads }; }).pipe(Effect.provide(layer)); }), ); @@ -263,6 +269,20 @@ describe("IssueCompletionReactor", () => { }), ); + it.effect("reads linked issues once their worktree merges so their state refreshes", () => + Effect.gen(function* () { + const merged = yield* runCase({ enabled: false, pullRequestState: "merged" }); + assert.strictEqual(merged.detailReads, 1); + assert.deepStrictEqual(merged.calls, []); + for (const input of [ + { enabled: false, pullRequestState: "open" }, + { enabled: false, pullRequestState: "merged", linkState: "closed" }, + ] as const) { + assert.strictEqual((yield* runCase(input)).detailReads, 0); + } + }), + ); + it.effect("finds a previous completion comment on later pages without posting it again", () => Effect.gen(function* () { const result = yield* runCase({ diff --git a/apps/server/src/issue/IssueCompletionReactor.ts b/apps/server/src/issue/IssueCompletionReactor.ts index b3c02921a1e50..6d8984207b3d8 100644 --- a/apps/server/src/issue/IssueCompletionReactor.ts +++ b/apps/server/src/issue/IssueCompletionReactor.ts @@ -1,4 +1,4 @@ -import { type ProjectId } from "@t3tools/contracts"; +import { type IssueLinkedWork, type ProjectId } from "@t3tools/contracts"; import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import * as Context from "effect/Context"; @@ -87,6 +87,28 @@ export const make = Effect.gen(function* () { ), ); + // GitHub closes issues a merged PR references; reading one records its state on the link. + const refreshState = (projectId: ProjectId, link: IssueLinkedWork, urls: string) => { + const key = `refresh:${link.issue.host}/${link.issue.repository}#${link.issue.number}:${urls}`; + if (handled.has(key)) return Effect.void; + handled.add(key); + const reference = { + projectId, + host: link.issue.host, + repository: link.issue.repository, + number: link.issue.number, + }; + return issues.invalidate({ reference }).pipe( + Effect.andThen(issues.detail(reference)), + Effect.asVoid, + Effect.catch((error) => + Effect.logWarning("linked issue state refresh failed", { error }).pipe( + Effect.andThen(Effect.sync(() => handled.delete(key))), + ), + ), + ); + }; + const completeProject = Effect.fn("IssueCompletionReactor.completeProject")(function* ( projectId: ProjectId, ) { @@ -103,6 +125,24 @@ export const make = Effect.gen(function* () { concurrency: 4, discard: true, }); + yield* Effect.forEach( + (project.worktreeIssues ?? []).filter( + (link) => link.state !== "closed" && !candidates.includes(link), + ), + (link) => { + const pullRequests = (project.worktreePullRequests ?? []).filter( + (pullRequest) => pullRequest.worktreePath === link.worktreePath, + ); + if ( + pullRequests.length === 0 || + pullRequests.some((pullRequest) => pullRequest.snapshot?.state !== "merged") + ) + return Effect.void; + const urls = [...new Set(pullRequests.map((pullRequest) => pullRequest.url))].sort(); + return refreshState(projectId, link, urls.join(",")); + }, + { concurrency: 4, discard: true }, + ); }); const worker = yield* makeDrainableWorker((projectId: ProjectId) => diff --git a/apps/server/src/issue/IssueService.ts b/apps/server/src/issue/IssueService.ts index 21da6199d69cc..7e27e768997a8 100644 --- a/apps/server/src/issue/IssueService.ts +++ b/apps/server/src/issue/IssueService.ts @@ -952,6 +952,24 @@ export const make = Effect.gen(function* () { Effect.map((row) => (row === null ? null : linkedWorkFromRow(repo, number, row))), ); + // Keeps the stored state of linked issues current; worktree chips read it from shells. + const recordLinkedState = (repo: Repo, number: number, state: IssueDetail["state"]) => + sql<{ readonly projectId: string }>` + UPDATE projection_issue_links SET state = ${state} + WHERE host = ${repo.host} AND repository = ${repo.repository} AND number = ${number} + AND state IS NOT ${state} + RETURNING project_id AS "projectId" + `.pipe( + Effect.flatMap((rows) => + Effect.forEach( + new Set(rows.map((row) => ProjectId.make(row.projectId))), + worktreeLinks.publish, + { discard: true }, + ), + ), + Effect.catch((cause) => Effect.logWarning("failed to record linked issue state", { cause })), + ); + const detail = (input: IssueRef): Effect.Effect => cached( detailCache, @@ -973,6 +991,7 @@ export const make = Effect.gen(function* () { if (issue === null) return yield* operationError("detail", "Issue was not found."); const viewer = stringValue(record(record(response.data).viewer).login).trim(); const linked = yield* linkedWork(repo, input.number); + if (linked !== null) yield* recordLinkedState(repo, input.number, issue.state); return { ...issue, projectTitle: repo.projectTitle, diff --git a/apps/server/src/persistence/ForkSchema/IssueLinks.ts b/apps/server/src/persistence/ForkSchema/IssueLinks.ts index 705fc5d1fa60e..5b517bcf23497 100644 --- a/apps/server/src/persistence/ForkSchema/IssueLinks.ts +++ b/apps/server/src/persistence/ForkSchema/IssueLinks.ts @@ -17,6 +17,11 @@ export default Effect.gen(function* () { detached_at TEXT ) `; + // Last issue state the server observed, so worktree chips can show closed issues. + const columns = yield* sql<{ readonly name: string }>`PRAGMA table_info(projection_issue_links)`; + if (!columns.some((column) => column.name === "state")) { + yield* sql`ALTER TABLE projection_issue_links ADD COLUMN state TEXT`; + } yield* sql` CREATE INDEX IF NOT EXISTS idx_projection_issue_links_issue ON projection_issue_links(host, repository, number) diff --git a/apps/server/src/project/ProjectWorktreeLinks.ts b/apps/server/src/project/ProjectWorktreeLinks.ts index b07d4e6317aa9..c89422563b71a 100644 --- a/apps/server/src/project/ProjectWorktreeLinks.ts +++ b/apps/server/src/project/ProjectWorktreeLinks.ts @@ -97,6 +97,7 @@ const IssueLinkRow = Schema.Struct({ linkedAt: IsoDateTime, branch: Schema.NullOr(Schema.String), worktreePath: Schema.NullOr(Schema.String), + state: Schema.NullOr(Schema.String), }); function toIssueLinkedWork(row: typeof IssueLinkRow.Type): IssueLinkedWork { @@ -108,6 +109,7 @@ function toIssueLinkedWork(row: typeof IssueLinkRow.Type): IssueLinkedWork { worktreePath: row.worktreePath === "" ? null : row.worktreePath, linkedAt: row.linkedAt, source: row.source === "created" || row.source === "agent" ? row.source : "manual", + ...(row.state === "open" || row.state === "closed" ? { state: row.state } : {}), }; } @@ -136,7 +138,8 @@ export const make = Effect.gen(function* () { source, linked_at AS "linkedAt", branch, - worktree_path AS "worktreePath" + worktree_path AS "worktreePath", + state FROM projection_issue_links WHERE detached_at IS NULL ${projectIds === undefined ? sql`` : sql`AND ${sql.in("project_id", projectIds)}`} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index aa767ab5156d0..684a41da8fb23 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1550,6 +1550,13 @@ function WorkspaceServersPopover(props: { ); } +// Matches GitHub: open issues are green, closed ones purple like merged pull requests. +const ISSUE_CHIP_TONE = { + open: "text-emerald-600 hover:text-emerald-700 dark:text-emerald-400 dark:hover:text-emerald-300", + closed: + "text-violet-600 hover:text-violet-700 dark:text-violet-300/90 dark:hover:text-violet-200", +} as const; + const SidebarWorktreeHeader = memo(function SidebarWorktreeHeader(props: { readonly environmentId: EnvironmentId; readonly projectId: ProjectId; @@ -1778,7 +1785,9 @@ const SidebarWorktreeHeader = memo(function SidebarWorktreeHeader(props: { ) : null} {props.issues.map((issue) => { const url = issueLinkExternalUrl(issue.issue); - const label = `GitHub issue #${issue.issue.number} · ${issue.issue.repository}`; + const closed = issue.state === "closed"; + const label = `GitHub issue #${issue.issue.number}${closed ? " (closed)" : ""} · ${issue.issue.repository}`; + const Icon = closed ? CircleCheckIcon : CircleDotIcon; return ( event.stopPropagation()} onClick={(event) => handleIssueOpen(event, issue)} - className="inline-flex h-7 shrink-0 cursor-pointer items-center gap-0.5 whitespace-nowrap text-xs tabular-nums text-emerald-600 underline-offset-2 hover:text-emerald-700 hover:underline focus-visible:outline-2 focus-visible:outline-ring dark:text-emerald-400 dark:hover:text-emerald-300" + className={cn( + "inline-flex h-7 shrink-0 cursor-pointer items-center gap-0.5 whitespace-nowrap text-xs tabular-nums underline-offset-2 hover:underline focus-visible:outline-2 focus-visible:outline-ring", + ISSUE_CHIP_TONE[closed ? "closed" : "open"], + )} /> } > - #{issue.issue.number} + #{issue.issue.number} {label} diff --git a/packages/contracts/src/issue.ts b/packages/contracts/src/issue.ts index 8b7dfb1f89508..55bbcc7a4f2c5 100644 --- a/packages/contracts/src/issue.ts +++ b/packages/contracts/src/issue.ts @@ -422,6 +422,8 @@ export const IssueLinkedWork = Schema.Struct({ worktreePath: Schema.NullOr(TrimmedNonEmptyString), linkedAt: IsoDateTime, source: IssueLinkSource, + /** Last state the server observed; absent until the issue is first read. */ + state: Schema.optional(IssueState), }); export type IssueLinkedWork = typeof IssueLinkedWork.Type; From 12ef006578b725c05dfacb1d378f33a82f271a20 Mon Sep 17 00:00:00 2001 From: Kaloyan Stoyanov Date: Sat, 3 Oct 2026 20:02:47 +0300 Subject: [PATCH 04/10] refactor(web): extract the shared work-item list shell from the pull request page Moves the condensing column, compact filter menus, folded search and refresh control into components/workItem so the issues page can share them, and moves the new-issue dialog into its own module. No visible change to the pull request page. --- .../components/issue/IssueCreateDialog.tsx | 511 ++++++++++++++++++ .../pullRequest/PullRequestGhosts.tsx | 14 +- .../pullRequest/PullRequestListFilters.tsx | 8 +- .../workItem/WorkItemListColumn.tsx | 408 ++++++++++++++ apps/web/src/routes/_chat.issues.tsx | 497 +---------------- apps/web/src/routes/_chat.pull-requests.tsx | 472 ++-------------- 6 files changed, 988 insertions(+), 922 deletions(-) create mode 100644 apps/web/src/components/issue/IssueCreateDialog.tsx create mode 100644 apps/web/src/components/workItem/WorkItemListColumn.tsx diff --git a/apps/web/src/components/issue/IssueCreateDialog.tsx b/apps/web/src/components/issue/IssueCreateDialog.tsx new file mode 100644 index 0000000000000..13f0a174e0b91 --- /dev/null +++ b/apps/web/src/components/issue/IssueCreateDialog.tsx @@ -0,0 +1,511 @@ +import type { EnvironmentId, IssueRepositorySelection, ProjectId } from "@t3tools/contracts"; +import { CircleDotIcon, ExternalLinkIcon } from "lucide-react"; +import { useEffect, useState } from "react"; + +import { readLocalApi } from "../../localApi"; +import { + issueEnvironment, + readIssueDraft, + useIssueCandidates, + useIssueTemplates, + writeIssueDraft, +} from "../../state/issues"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { Button } from "../ui/button"; +import { + Combobox, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, + ComboboxPopup, +} from "../ui/combobox"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "../ui/dialog"; +import { Input } from "../ui/input"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; +import { Spinner } from "../ui/spinner"; +import { Textarea } from "../ui/textarea"; +import { issueRepositoryUrl } from "./issue.logic"; +import { IssueLabelPill } from "./IssueLabelPill"; + +const BLANK_ISSUE_TEMPLATE = "__blank__"; + +interface IssueCreateDialogProps { + readonly open: boolean; + readonly onOpenChange: (open: boolean) => void; + readonly selection: IssueRepositorySelection | null; + readonly environmentId: EnvironmentId | null; + readonly onCreated: (issue: { + readonly projectId: ProjectId; + readonly host: string; + readonly repository: string; + readonly number: number; + readonly url: string; + }) => void; +} +export function IssueCreateDialog({ + open, + onOpenChange, + selection, + environmentId, + onCreated, +}: IssueCreateDialogProps) { + const templatesQuery = useIssueTemplates( + open && selection && environmentId ? { environmentId, input: selection } : null, + ); + const [templateId, setTemplateId] = useState(""); + const [title, setTitle] = useState(""); + const [body, setBody] = useState(""); + const [labels, setLabels] = useState(""); + const [assignees, setAssignees] = useState(""); + const [error, setError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const assigneeQuery = assignees.split(",").at(-1)?.trim().slice(0, 200) ?? ""; + const labelsCandidatesQuery = useIssueCandidates( + open && selection && environmentId + ? { + environmentId, + input: { + ...selection, + kind: "labels", + limit: 100, + }, + } + : null, + ); + const assigneesCandidatesQuery = useIssueCandidates( + open && selection && environmentId + ? { + environmentId, + input: { + ...selection, + kind: "assignees", + limit: 100, + ...(assigneeQuery ? { query: assigneeQuery } : {}), + }, + } + : null, + ); + const create = useAtomCommand(issueEnvironment.create, { reportFailure: false }); + useEffect(() => { + if (!open || !selection) return; + const draft = readIssueDraft( + typeof window === "undefined" ? undefined : window.localStorage, + selection, + ); + setTemplateId(draft?.templateId ?? ""); + setTitle(draft?.title ?? ""); + setBody(draft?.body ?? ""); + setLabels(draft?.labels.join(", ") ?? ""); + setAssignees(draft?.assignees.join(", ") ?? ""); + setError(null); + setIsSubmitting(false); + }, [open, selection]); + useEffect(() => { + if (!open || !selection) return; + const timer = window.setTimeout( + () => + writeIssueDraft( + typeof window === "undefined" ? undefined : window.localStorage, + selection, + { + ...(templateId ? { templateId } : {}), + title, + body, + labels: labels + .split(",") + .map((value) => value.trim()) + .filter(Boolean), + assignees: assignees + .split(",") + .map((value) => value.trim()) + .filter(Boolean), + }, + ), + 250, + ); + return () => window.clearTimeout(timer); + }, [assignees, body, labels, open, selection, templateId, title]); + const selectedTemplate = + templatesQuery.data?.templates.find((template) => template.id === templateId) ?? null; + const applyTemplate = (id: string) => { + setTemplateId(id); + const template = templatesQuery.data?.templates.find((entry) => entry.id === id); + if (!template) { + setTitle(""); + setBody(""); + setLabels(""); + setAssignees(""); + return; + } + setTitle(template.title ?? ""); + setBody(template.body ?? ""); + setLabels(template.labels.join(", ")); + setAssignees(template.assignees.join(", ")); + }; + const submit = async () => { + if ( + isSubmitting || + selectedTemplate?.kind === "issue-form" || + !selection || + environmentId === null || + title.trim().length === 0 + ) { + return; + } + setIsSubmitting(true); + setError(null); + try { + const confirmed = + (await readLocalApi()?.dialogs.confirm("Create this issue on GitHub?")) ?? true; + if (!confirmed) return; + const result = await create({ + environmentId, + input: { + ...selection, + title: title.trim(), + body, + ...(templateId ? { templateId } : {}), + ...(labels.trim() + ? { + labels: labels + .split(",") + .map((value) => value.trim()) + .filter(Boolean), + } + : {}), + ...(assignees.trim() + ? { + assignees: assignees + .split(",") + .map((value) => value.trim()) + .filter(Boolean), + } + : {}), + }, + }); + if (result._tag === "Success") { + const issue = result.value.issue; + onCreated({ + projectId: issue.projectId, + host: issue.host, + repository: issue.repository, + number: issue.number, + url: issue.url, + }); + return; + } + setError("Could not create the issue. Your draft is still saved; try again."); + } catch { + setError("Could not create the issue. Your draft is still saved; try again."); + } finally { + setIsSubmitting(false); + } + }; + const openExternalForm = () => { + if (!selection || isSubmitting) return; + void readLocalApi()?.shell.openExternal(`${issueRepositoryUrl(selection)}/issues/new`); + }; + const labelCandidates = + labelsCandidatesQuery.data?._tag === "labels" + ? labelsCandidatesQuery.data.candidates.map((candidate) => ({ + value: candidate.name, + label: candidate.name, + color: candidate.color, + })) + : []; + const assigneeCandidates = + assigneesCandidatesQuery.data?._tag === "assignees" + ? assigneesCandidatesQuery.data.candidates.map((candidate) => ({ + value: candidate.login, + label: candidate.login, + detail: candidate.name, + })) + : []; + return ( + + + + + + New issue + + + Create an issue in {selection?.repository ?? "the selected repository"}. + + + + {templatesQuery.isPending ? ( +
+ Checking available templates… +
+ ) : null} + {templatesQuery.error ? ( +
+ + Templates could not be loaded. You can still create a blank issue. + + +
+ ) : null} +
+ + +
+ {selectedTemplate?.kind === "issue-form" ? ( +
+

This template is a structured GitHub form.

+

+ T3 Code keeps the form fields on GitHub so validation and dropdowns work correctly. +

+ +
+ ) : null} + +