diff --git a/src-tauri/crates/project-management/src/orchestrator/commands.rs b/src-tauri/crates/project-management/src/orchestrator/commands.rs index a9d6829947..4c02f9726a 100644 --- a/src-tauri/crates/project-management/src/orchestrator/commands.rs +++ b/src-tauri/crates/project-management/src/orchestrator/commands.rs @@ -16,9 +16,15 @@ use super::state_machine; use crate::projects::io::orchestrator_view; use core_types::session::PENDING_SESSION_PLACEHOLDER; -fn emit_data_changed(app: &tauri::AppHandle) { - let ts = chrono::Utc::now().to_rfc3339(); - let _ = app.emit(DATA_CHANGED_EVENT, &ts); +fn emit_data_changed(app: &tauri::AppHandle, project_slug: &str, work_item_id: &str) { + let _ = app.emit( + DATA_CHANGED_EVENT, + serde_json::json!({ + "project_slug": project_slug, + "work_item_id": work_item_id, + "source": "orchestrator", + }), + ); } #[derive(Debug, Clone, Serialize)] @@ -39,6 +45,7 @@ pub async fn orchestrator_start( work_item_id: String, app: tauri::AppHandle, ) -> Result { + let event_project_slug = project_slug.clone(); let result = tokio::task::spawn_blocking(move || { projects_io::update_work_item_atomic( &project_slug, @@ -87,7 +94,7 @@ pub async fn orchestrator_start( .await .map_err(|err| err.to_string())??; - emit_data_changed(&app); + emit_data_changed(&app, &event_project_slug, &result); Ok(result) } @@ -98,6 +105,8 @@ pub async fn orchestrator_cancel( work_item_id: String, app: tauri::AppHandle, ) -> Result<(), String> { + let event_project_slug = project_slug.clone(); + let event_work_item_id = work_item_id.clone(); tokio::task::spawn_blocking(move || -> Result<(), String> { state_machine::mutate_work_item(&project_slug, &work_item_id, |frontmatter| { state_machine::cancel(frontmatter); @@ -108,7 +117,7 @@ pub async fn orchestrator_cancel( .await .map_err(|err| err.to_string())??; - emit_data_changed(&app); + emit_data_changed(&app, &event_project_slug, &event_work_item_id); Ok(()) } @@ -119,6 +128,8 @@ pub async fn orchestrator_retry( work_item_id: String, app: tauri::AppHandle, ) -> Result<(), String> { + let event_project_slug = project_slug.clone(); + let event_work_item_id = work_item_id.clone(); tokio::task::spawn_blocking(move || { let state = orchestrator_view::read_orchestrator_state(&project_slug, &work_item_id)? .ok_or("No orchestrator state")?; @@ -163,7 +174,7 @@ pub async fn orchestrator_retry( .await .map_err(|err| err.to_string())??; - emit_data_changed(&app); + emit_data_changed(&app, &event_project_slug, &event_work_item_id); Ok(()) } @@ -226,13 +237,15 @@ pub async fn orchestrator_create_follow_up( review_feedback: String, app: tauri::AppHandle, ) -> Result { + let event_project_slug = project_slug.clone(); + let event_parent_short_id = parent_short_id.clone(); let result = tokio::task::spawn_blocking(move || { super::follow_up::create_follow_up(&project_slug, &parent_short_id, &review_feedback) }) .await .map_err(|err| err.to_string())??; - emit_data_changed(&app); + emit_data_changed(&app, &event_project_slug, &event_parent_short_id); Ok(result) } @@ -273,6 +286,8 @@ pub async fn orchestrator_set_pr( pr_status: String, app: tauri::AppHandle, ) -> Result<(), String> { + let event_project_slug = project_slug.clone(); + let event_work_item_id = work_item_id.clone(); tokio::task::spawn_blocking(move || { let status = match pr_status.as_str() { "open" => PrStatus::Open, @@ -291,6 +306,6 @@ pub async fn orchestrator_set_pr( .await .map_err(|err| err.to_string())??; - emit_data_changed(&app); + emit_data_changed(&app, &event_project_slug, &event_work_item_id); Ok(()) } diff --git a/src-tauri/crates/project-management/src/projects/commands/work_items.rs b/src-tauri/crates/project-management/src/projects/commands/work_items.rs index 8011e7c7eb..c14e43340a 100644 --- a/src-tauri/crates/project-management/src/projects/commands/work_items.rs +++ b/src-tauri/crates/project-management/src/projects/commands/work_items.rs @@ -120,6 +120,22 @@ pub async fn project_read_work_item( .map_err(|err| format!("Task join error: {}", err))? } +/// Read one work item with its labels, members, project, and milestone already +/// resolved. Detail surfaces should prefer this over reading the entire project +/// collection and filtering it in JavaScript. +#[tauri::command] +pub async fn project_read_work_item_enriched( + project_slug: String, + short_id: String, + org_id: Option, +) -> Result { + tokio::task::spawn_blocking(move || { + io::read_work_item_enriched_scoped(&project_slug, &short_id, org_id.as_deref()) + }) + .await + .map_err(|err| format!("Task join error: {}", err))? +} + #[tauri::command] pub async fn work_item_read_standalone_items( org_id: Option, diff --git a/src-tauri/src/commands/handler_list.inc b/src-tauri/src/commands/handler_list.inc index a99333dbee..db964afa65 100644 --- a/src-tauri/src/commands/handler_list.inc +++ b/src-tauri/src/commands/handler_list.inc @@ -590,6 +590,7 @@ project_management::projects::commands::project_read_work_items_enriched, project_management::projects::commands::project_read_workspace_work_items_data, project_management::projects::commands::project_read_work_items_view_data, project_management::projects::commands::project_read_work_item, +project_management::projects::commands::project_read_work_item_enriched, project_management::projects::commands::work_item_read_standalone_items, project_management::projects::commands::work_item_read_standalone_item, project_management::projects::commands::project_write_work_item, diff --git a/src/api/http/project/cache.test.ts b/src/api/http/project/cache.test.ts index 78c38a4445..3cec15e838 100644 --- a/src/api/http/project/cache.test.ts +++ b/src/api/http/project/cache.test.ts @@ -51,4 +51,39 @@ describe("project read cache invalidation fencing", () => { ); expect(fetcher).toHaveBeenCalledTimes(2); }); + + it("does not restart an unrelated project read after scoped invalidation", async () => { + let resolveStaleAlpha: ((value: string) => void) | undefined; + let resolveBeta: ((value: string) => void) | undefined; + const alphaFetcher = vi + .fn<() => Promise>() + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveStaleAlpha = resolve; + }) + ) + .mockResolvedValue("alpha-fresh"); + const betaFetcher = vi.fn( + () => + new Promise((resolve) => { + resolveBeta = resolve; + }) + ); + + const alphaBeforeMutation = cachedRead("alpha:workitems", alphaFetcher); + const betaBeforeMutation = cachedRead("beta:workitems", betaFetcher); + await Promise.resolve(); + invalidateCache("alpha"); + const alphaAfterMutation = cachedRead("alpha:workitems", alphaFetcher); + + resolveBeta?.("beta-current"); + await expect(betaBeforeMutation).resolves.toBe("beta-current"); + expect(betaFetcher).toHaveBeenCalledTimes(1); + + await expect(alphaAfterMutation).resolves.toBe("alpha-fresh"); + resolveStaleAlpha?.("alpha-stale"); + await expect(alphaBeforeMutation).resolves.toBe("alpha-fresh"); + expect(alphaFetcher).toHaveBeenCalledTimes(2); + }); }); diff --git a/src/api/http/project/cache.ts b/src/api/http/project/cache.ts index 6fa208980b..1ae3348579 100644 --- a/src/api/http/project/cache.ts +++ b/src/api/http/project/cache.ts @@ -11,8 +11,8 @@ * - Entries expire after `CACHE_TTL_MS` (2 seconds). * - In-flight promises are shared (request deduplication). * - `invalidateCache(slug)` drops every key starting with `${slug}:`; - * `invalidateCache()` flushes the whole cache (used by the global - * `orgii-data-changed` listener since the event doesn't carry a slug). + * `invalidateCache()` flushes the whole cache for legacy/unscoped + * `orgii-data-changed` events. * - Max 50 entries with FIFO eviction. */ @@ -32,7 +32,31 @@ const inflight = new Map>(); * still resolve later, repopulate the cache with its pre-mutation snapshot, * and hand that stale snapshot to an open Work Item detail. */ -let invalidationGeneration = 0; +let globalInvalidationGeneration = 0; +const scopedInvalidationGenerations = new Map(); +const activeReadsByScope = new Map(); + +function cacheScope(cacheKey: string): string { + const separatorIndex = cacheKey.indexOf(":"); + return separatorIndex >= 0 ? cacheKey.slice(0, separatorIndex) : cacheKey; +} + +function beginScopedRead(scope: string): void { + activeReadsByScope.set(scope, (activeReadsByScope.get(scope) ?? 0) + 1); +} + +function endScopedRead(scope: string): void { + const remaining = (activeReadsByScope.get(scope) ?? 1) - 1; + if (remaining > 0) { + activeReadsByScope.set(scope, remaining); + return; + } + activeReadsByScope.delete(scope); + // A scoped generation only fences requests that crossed its mutation. + // Once all requests in that scope have settled, retaining it has no value + // and would turn project slugs into an unbounded registry. + scopedInvalidationGenerations.delete(scope); +} function evictIfNeeded(): void { if (cache.size < MAX_ENTRIES) return; @@ -55,10 +79,24 @@ export async function cachedRead( return pending as Promise; } - const requestGeneration = invalidationGeneration; - const promise = fetcher() + const scope = cacheScope(cacheKey); + const requestGlobalGeneration = globalInvalidationGeneration; + const requestScopedGeneration = scopedInvalidationGenerations.get(scope) ?? 0; + beginScopedRead(scope); + let fetchPromise: Promise; + try { + fetchPromise = fetcher(); + } catch (error) { + endScopedRead(scope); + throw error; + } + const promise = fetchPromise .then(async (result): Promise => { - if (requestGeneration !== invalidationGeneration) { + if ( + requestGlobalGeneration !== globalInvalidationGeneration || + requestScopedGeneration !== + (scopedInvalidationGenerations.get(scope) ?? 0) + ) { // This request crossed a mutation boundary. Never expose/cache its // stale snapshot; converge the original waiter onto the post-change // read (or its already-running shared Promise) instead. @@ -75,6 +113,9 @@ export async function cachedRead( // under the same key after invalidation. if (inflight.get(cacheKey) === promise) inflight.delete(cacheKey); throw err; + }) + .finally(() => { + endScopedRead(scope); }); inflight.set(cacheKey, promise); @@ -83,15 +124,22 @@ export async function cachedRead( /** * Drop cached entries scoped to `slug`. Pass no argument to flush the - * whole cache (used by the project-data-changed event listener). + * whole cache when a project-data-changed event has no safe scope. */ export function invalidateCache(slug?: string): void { - invalidationGeneration += 1; if (!slug) { + globalInvalidationGeneration += 1; + scopedInvalidationGenerations.clear(); cache.clear(); inflight.clear(); return; } + if ((activeReadsByScope.get(slug) ?? 0) > 0) { + scopedInvalidationGenerations.set( + slug, + (scopedInvalidationGenerations.get(slug) ?? 0) + 1 + ); + } const prefix = `${slug}:`; for (const key of cache.keys()) { if (key.startsWith(prefix)) { diff --git a/src/api/http/project/client.ts b/src/api/http/project/client.ts index 8bde180a95..9cadddbaa8 100644 --- a/src/api/http/project/client.ts +++ b/src/api/http/project/client.ts @@ -428,6 +428,23 @@ export async function readWorkItem( }); } +export async function readWorkItemEnriched( + projectSlug: string, + shortId: string, + options?: ProjectScopeOptions +): Promise { + const scopeSegment = scopeCacheSegment(options); + return cachedRead( + `${projectSlug}:workitem-enriched:${shortId}:${scopeSegment}`, + () => + invoke("project_read_work_item_enriched", { + projectSlug, + shortId, + ...scopeInvokePayload(options), + }) + ); +} + export async function readStandaloneWorkItems( options?: WorkItemsReadOptions ): Promise { diff --git a/src/api/http/project/index.ts b/src/api/http/project/index.ts index 0ca4bc3b30..8c22fd7b7a 100644 --- a/src/api/http/project/index.ts +++ b/src/api/http/project/index.ts @@ -82,6 +82,7 @@ export const projectApi = { writeMembers: client.writeMembers, // Work items readWorkItem: client.readWorkItem, + readWorkItemEnriched: client.readWorkItemEnriched, readStandaloneWorkItem: client.readStandaloneWorkItem, readStandaloneWorkItems: client.readStandaloneWorkItems, readWorkItems: client.readWorkItems, diff --git a/src/engines/ChatPanel/panels/ProjectPanelView.tsx b/src/engines/ChatPanel/panels/ProjectPanelView.tsx index 488e63ceb4..9c7a246cd3 100644 --- a/src/engines/ChatPanel/panels/ProjectPanelView.tsx +++ b/src/engines/ChatPanel/panels/ProjectPanelView.tsx @@ -274,9 +274,14 @@ export const ProjectPanelView: React.FC = ({ }, [loadProjectWorkItems]); useProjectDataChanged( - useCallback(() => { - void loadProjectWorkItems(); - }, [loadProjectWorkItems]) + useCallback( + (change) => { + if (!change?.projectSlug || change.projectSlug === projectSlug) { + void loadProjectWorkItems(); + } + }, + [loadProjectWorkItems, projectSlug] + ) ); useEffect(() => { diff --git a/src/engines/ChatPanel/panels/WorkItemPanelView.tsx b/src/engines/ChatPanel/panels/WorkItemPanelView.tsx index 94b80dd3f9..69c7d54982 100644 --- a/src/engines/ChatPanel/panels/WorkItemPanelView.tsx +++ b/src/engines/ChatPanel/panels/WorkItemPanelView.tsx @@ -1,12 +1,17 @@ import { emit } from "@tauri-apps/api/event"; import { useAtomValue, useSetAtom } from "jotai"; import { ListChecks, SquareArrowOutUpRight, Trash2, X } from "lucide-react"; -import React, { useCallback, useEffect, useMemo, useState } from "react"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { useTranslation } from "react-i18next"; import { STORY_SYNC_ADAPTER } from "@src/api/http/integrations/syncConnections"; import { - type WorkItemFrontmatter, enrichedWorkItemToUI, projectApi, standaloneWorkItemDataToEnriched, @@ -36,6 +41,7 @@ import { confirmDestructiveAction } from "@src/util/dialogs/confirmDestructiveAc import SessionContentView from "../SessionContentView"; import { usePendingWorkItemAction } from "./usePendingWorkItemAction"; +import { useWorkItemGitHubIssueState } from "./useWorkItemGitHubIssueState"; const logger = createLogger("WorkItemPanelView"); const saveNoPendingWorkItemChanges = async (): Promise => undefined; @@ -46,46 +52,6 @@ interface WorkItemPanelViewProps { onClose?: () => void; } -function toStandaloneFrontmatter( - workItem: WorkItem, - shortId: string -): WorkItemFrontmatter { - const now = new Date().toISOString(); - return { - id: shortId, - short_id: shortId, - title: workItem.name, - project: workItem.project?.id, - status: workItem.workItemStatus ?? workItem.status ?? "backlog", - priority: workItem.priority ?? "none", - assignee: workItem.assignee?.id, - assignee_type: workItem.assigneeType, - labels: workItem.labels?.map((label) => label.id) ?? [], - milestone: workItem.milestone?.id, - start_date: workItem.startDate, - target_date: workItem.endDate ?? workItem.target_date ?? undefined, - created_at: workItem.created_time || now, - updated_at: now, - starred: workItem.star ?? false, - todos: - workItem.todos?.map((todo) => ({ - id: todo.id, - content: todo.content, - status: todo.status, - })) ?? [], - comments: workItem.comments, - linked_sessions: workItem.linkedSessions, - proof_of_work: workItem.proofOfWork, - orchestrator_config: workItem.orchestratorConfig, - orchestrator_state: workItem.orchestratorState, - schedule: workItem.schedule ?? undefined, - routine_source: workItem.routineSource, - execution_lock: workItem.executionLock, - close_out: workItem.closeOut, - work_products: workItem.workProducts, - }; -} - function applyWorkItemPatch( workItem: WorkItem, updates: Partial @@ -127,10 +93,14 @@ export const WorkItemPanelView: React.FC = ({ ] ); const { currentUser } = useCurrentUserMemberIds(workItemMembers); + const sourceProjectSyncAdapterId = + selectedWorkItem.sourceProject?.project.syncAdapterId; useEffect(() => { const projectSlug = selectedWorkItem.projectSlug; - if (!projectSlug) return; + // Navigation already carries the canonical project record. Only fall back + // to a status IPC for older/restored tab payloads that lack that field. + if (!projectSlug || sourceProjectSyncAdapterId !== undefined) return; let cancelled = false; void projectSyncApi @@ -152,7 +122,7 @@ export const WorkItemPanelView: React.FC = ({ return () => { cancelled = true; }; - }, [selectedWorkItem.projectSlug]); + }, [selectedWorkItem.projectSlug, sourceProjectSyncAdapterId]); const handleUpdateWorkItem = useCallback( async (updates: Partial) => { @@ -198,7 +168,11 @@ export const WorkItemPanelView: React.FC = ({ workItem: updatedWorkItem, }); } - await emit("orgii-data-changed"); + await emit("orgii-data-changed", { + project_slug: selectedWorkItem.projectSlug || undefined, + work_item_id: selectedWorkItem.shortId, + source: "chat-panel-work-item-update", + }); } catch (error) { logger.error("Failed to update chat panel work item", error); } @@ -206,30 +180,15 @@ export const WorkItemPanelView: React.FC = ({ [currentUser, onUpdateWorkItem, selectedWorkItem, setSelectedWorkItem] ); - const refreshSelectedWorkItem = useCallback(async () => { + const refreshSelectedWorkItemOnce = useCallback(async () => { try { if (selectedWorkItem.projectSlug) { - const projects = await projectApi.readProjects(); - const projectStillExists = projects.some( - (project) => project.slug === selectedWorkItem.projectSlug - ); - if (!projectStillExists) { - // Reading the deleted project's items throws before it can return an - // empty list, so detect the parent tombstone explicitly. The local - // project store is authoritative even when cloud transport is down. - // Close the owning tab too: its payload, not the legacy selection - // atom, is what keeps the detail surface mounted. - closeWorkItemTab(selectedWorkItem.shortId); - return; - } - const items = await projectApi.readWorkItemsEnriched( + const fresh = await projectApi.readWorkItemEnriched( selectedWorkItem.projectSlug, + selectedWorkItem.shortId, selectedWorkItem.orgId ? { orgId: selectedWorkItem.orgId } : undefined ); - const fresh = items.find( - (item) => item.shortId === selectedWorkItem.shortId - ); - if (!fresh || fresh.deletedAt) { + if (fresh.deletedAt) { // A collaborator may delete the item itself or its parent project // while this detail is open. Enriched reads intentionally retain // soft-deleted rows, so a tombstone must be treated as absent too; @@ -263,14 +222,60 @@ export const WorkItemPanelView: React.FC = ({ : current ); } catch (error) { + if (String(error).toLowerCase().includes("not found")) { + // The single-item command resolves both the parent and item at the + // authoritative SQLite boundary. Either tombstone makes this cached + // tab invalid, without scanning every project and every work item. + closeWorkItemTab(selectedWorkItem.shortId); + return; + } logger.warn("Failed to refresh chat panel work item", error); } }, [closeWorkItemTab, selectedWorkItem, setSelectedWorkItem]); + const refreshOnceRef = useRef(refreshSelectedWorkItemOnce); + const refreshInFlightRef = useRef | null>(null); + useEffect(() => { + refreshOnceRef.current = refreshSelectedWorkItemOnce; + }, [refreshSelectedWorkItemOnce]); + + const refreshSelectedWorkItem = useCallback((): Promise => { + if (refreshInFlightRef.current) { + return refreshInFlightRef.current; + } + + const request = refreshOnceRef.current().finally(() => { + if (refreshInFlightRef.current === request) { + refreshInFlightRef.current = null; + } + }); + refreshInFlightRef.current = request; + return request; + }, []); + useProjectDataChanged( - useCallback(() => { - void refreshSelectedWorkItem(); - }, [refreshSelectedWorkItem]), + useCallback( + (change) => { + if ( + change?.projectSlug && + change.projectSlug !== selectedWorkItem.projectSlug + ) { + return; + } + if ( + change?.workItemId && + change.workItemId !== selectedWorkItem.shortId + ) { + return; + } + void refreshSelectedWorkItem(); + }, + [ + refreshSelectedWorkItem, + selectedWorkItem.projectSlug, + selectedWorkItem.shortId, + ] + ), // A detail surface can mount from a cached navigation payload after the // mutation signal already fired. Refreshing on mount closes that race; // subsequent signals keep the open panel live. @@ -343,9 +348,10 @@ export const WorkItemPanelView: React.FC = ({ selectedWorkItem.shortId || selectedWorkItem.workItem.session_id }`; const projectSyncAdapterId = - projectSyncAdapter?.projectSlug === selectedWorkItem.projectSlug + sourceProjectSyncAdapterId ?? + (projectSyncAdapter?.projectSlug === selectedWorkItem.projectSlug ? projectSyncAdapter.adapterId - : undefined; + : undefined); const isGitHubSyncedProject = projectSyncAdapterId === STORY_SYNC_ADAPTER.GITHUB; const selectedWorkItemStatus = @@ -355,6 +361,12 @@ export const WorkItemPanelView: React.FC = ({ isGitHubSyncedProject || selectedWorkItemStatus === WORK_ITEM_STATUS.GITHUB_OPEN || selectedWorkItemStatus === WORK_ITEM_STATUS.GITHUB_CLOSED; + const githubIssueState = useWorkItemGitHubIssueState({ + enabled: isGitHubWorkItem, + repoPath, + shortId: selectedWorkItem.shortId, + stateScopeKey: `chat-panel-work-item:${selectedWorkItem.orgId ?? "local"}:${selectedWorkItem.projectSlug}:${selectedWorkItem.shortId}`, + }); const projectSelectionReadonly = Boolean(selectedWorkItem.projectSlug) && (projectSyncAdapterId === undefined || isGitHubSyncedProject); @@ -380,7 +392,11 @@ export const WorkItemPanelView: React.FC = ({ // mirror leaves the deleted detail mounted until another data-change // refresh happens, and a later cascade can fall back to that ghost tab. closeWorkItemTab(selectedWorkItem.shortId); - await emit("orgii-data-changed"); + await emit("orgii-data-changed", { + project_slug: selectedWorkItem.projectSlug, + work_item_id: selectedWorkItem.shortId, + source: "chat-panel-work-item-delete", + }); } catch (error) { logger.error("Failed to delete chat panel work item", error); } @@ -492,6 +508,8 @@ export const WorkItemPanelView: React.FC = ({ repoPath={repoPath} projectSlug={selectedWorkItem.projectSlug} shortId={selectedWorkItem.shortId} + githubIssueTimeline={githubIssueState.timeline} + githubIssueInteraction={githubIssueState.interaction} onStartAgent={handleStartAgent} isStartingAgent={isStartingAgent} onCancelAgent={handleCancelAgent} diff --git a/src/engines/ChatPanel/panels/useWorkItemGitHubIssueState.test.ts b/src/engines/ChatPanel/panels/useWorkItemGitHubIssueState.test.ts new file mode 100644 index 0000000000..de3378d019 --- /dev/null +++ b/src/engines/ChatPanel/panels/useWorkItemGitHubIssueState.test.ts @@ -0,0 +1,255 @@ +// @vitest-environment jsdom +import { act, createElement } from "react"; +import { type Root, createRoot } from "react-dom/client"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +import type { GitHubIssueInteractionConfig } from "@src/modules/ProjectManager/WorkItems/components/WorkItemContent/types"; +import { resetGitHubIssueDetailCoordinator } from "@src/modules/shared/githubIssueDetailCoordinator"; +import { workstationIssueDetailScopeKey } from "@src/store/workstation/codeEditor/workstationIssueAtom"; + +import { useWorkItemGitHubIssueState } from "./useWorkItemGitHubIssueState"; + +const mocks = vi.hoisted(() => ({ + resolveGitHubIssueRemoteUrl: vi.fn(), + useGitHubIssueDetailState: vi.fn(), +})); + +vi.mock("@src/modules/ProjectManager/WorkItems/githubIssueRemote", () => ({ + resolveGitHubIssueRemoteUrl: mocks.resolveGitHubIssueRemoteUrl, +})); + +vi.mock("@src/modules/shared/hooks/useGitHubIssueDetailState", () => ({ + useGitHubIssueDetailState: mocks.useGitHubIssueDetailState, +})); + +function createInteraction(): GitHubIssueInteractionConfig { + return { + viewer: null, + issueState: "open", + duplicateCandidates: [], + duplicateCandidatesLoaded: false, + loadingDuplicateCandidates: false, + duplicateCandidatesError: false, + loading: false, + canComment: false, + canEditBody: false, + canManageStatus: false, + submittingComment: false, + updatingBody: false, + updatingStatus: false, + error: null, + onAddComment: vi.fn(async () => undefined), + onUpdateBody: vi.fn(async () => undefined), + onLoadDuplicateCandidates: vi.fn(async () => undefined), + onStatusChange: vi.fn(async () => undefined), + }; +} + +function Probe({ + enabled = true, + repoPath = "/workspace/orgii", + shortId = "705", +}: { + enabled?: boolean; + repoPath?: string; + shortId?: string; +}) { + const state = useWorkItemGitHubIssueState({ + enabled, + repoPath, + shortId, + stateScopeKey: "work-item-github-state-test", + }); + + return createElement("div", { + "data-testid": "probe", + "data-has-interaction": String(Boolean(state.interaction)), + "data-loading": String(state.interaction?.loading), + "data-timeline-count": String(state.timeline?.items.length ?? 0), + }); +} + +describe("useWorkItemGitHubIssueState", () => { + let container: HTMLDivElement; + let root: Root; + const actEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + }; + + beforeAll(() => { + actEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + }); + + beforeEach(() => { + vi.clearAllMocks(); + resetGitHubIssueDetailCoordinator(); + mocks.resolveGitHubIssueRemoteUrl.mockResolvedValue( + "git@github.com:org2AI/ORG2.git" + ); + mocks.useGitHubIssueDetailState.mockImplementation( + ({ + issueNumber, + remoteUrl, + }: { + issueNumber?: number; + remoteUrl?: string; + }) => ({ + selectedState: { + issue: remoteUrl ? { number: issueNumber } : null, + timeline: remoteUrl ? [{ id: 1, event: "commented" }] : [], + loading: false, + timelineLoading: false, + error: null, + submittingComment: false, + }, + interaction: createInteraction(), + assigneeConfig: undefined, + }) + ); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + afterAll(() => { + Reflect.deleteProperty(actEnvironment, "IS_REACT_ACT_ENVIRONMENT"); + }); + + it("hydrates the canonical interaction and timeline from the linked checkout", async () => { + let resolveRemoteUrl: ((remoteUrl: string) => void) | undefined; + mocks.resolveGitHubIssueRemoteUrl.mockImplementation( + () => + new Promise((resolve) => { + resolveRemoteUrl = resolve; + }) + ); + + await act(async () => { + root.render(createElement(Probe)); + }); + + expect( + container + .querySelector("[data-testid='probe']") + ?.getAttribute("data-loading") + ).toBe("true"); + + await act(async () => { + resolveRemoteUrl?.("git@github.com:org2AI/ORG2.git"); + await Promise.resolve(); + }); + + await vi.waitFor(() => { + expect( + container + .querySelector("[data-testid='probe']") + ?.getAttribute("data-loading") + ).toBe("false"); + }); + + expect(mocks.resolveGitHubIssueRemoteUrl).toHaveBeenCalledWith( + "/workspace/orgii" + ); + expect(mocks.useGitHubIssueDetailState).toHaveBeenLastCalledWith({ + issueNumber: 705, + repoPath: "/workspace/orgii", + remoteUrl: "git@github.com:org2AI/ORG2.git", + stateScopeKey: workstationIssueDetailScopeKey("/workspace/orgii", 705), + }); + expect( + container + .querySelector("[data-testid='probe']") + ?.getAttribute("data-timeline-count") + ).toBe("1"); + }); + + it("does not resolve or expose GitHub state for local Work Items", () => { + act(() => root.render(createElement(Probe, { enabled: false }))); + + expect(mocks.resolveGitHubIssueRemoteUrl).not.toHaveBeenCalled(); + expect( + container + .querySelector("[data-testid='probe']") + ?.getAttribute("data-has-interaction") + ).toBe("false"); + }); + + it("does not expose a dead composer when no GitHub remote resolves", async () => { + mocks.resolveGitHubIssueRemoteUrl.mockResolvedValue(null); + + await act(async () => { + root.render(createElement(Probe)); + }); + await vi.waitFor(() => { + expect( + container + .querySelector("[data-testid='probe']") + ?.getAttribute("data-has-interaction") + ).toBe("false"); + }); + }); + + it("ignores a stale remote lookup after the Work Item scope changes", async () => { + const pending = new Map void>(); + mocks.resolveGitHubIssueRemoteUrl.mockImplementation( + (repoPath: string) => + new Promise((resolve) => { + pending.set(repoPath, resolve); + }) + ); + + act(() => { + root.render( + createElement(Probe, { + repoPath: "/workspace/first", + shortId: "705", + }) + ); + }); + act(() => { + root.render( + createElement(Probe, { + repoPath: "/workspace/second", + shortId: "706", + }) + ); + }); + + await act(async () => { + pending.get("/workspace/first")?.("git@github.com:org/first.git"); + await Promise.resolve(); + }); + expect( + mocks.useGitHubIssueDetailState.mock.calls.some( + ([options]) => options.remoteUrl === "git@github.com:org/first.git" + ) + ).toBe(false); + + await act(async () => { + pending.get("/workspace/second")?.("git@github.com:org/second.git"); + await Promise.resolve(); + }); + await vi.waitFor(() => { + expect(mocks.useGitHubIssueDetailState).toHaveBeenLastCalledWith({ + issueNumber: 706, + repoPath: "/workspace/second", + remoteUrl: "git@github.com:org/second.git", + stateScopeKey: workstationIssueDetailScopeKey("/workspace/second", 706), + }); + }); + }); +}); diff --git a/src/engines/ChatPanel/panels/useWorkItemGitHubIssueState.ts b/src/engines/ChatPanel/panels/useWorkItemGitHubIssueState.ts new file mode 100644 index 0000000000..e7835bb17e --- /dev/null +++ b/src/engines/ChatPanel/panels/useWorkItemGitHubIssueState.ts @@ -0,0 +1,120 @@ +import { useStore } from "jotai"; +import { useEffect, useMemo, useState } from "react"; + +import type { GitHubIssueTimelineItem } from "@src/api/tauri/github"; +import { parseGitHubIssueNumber } from "@src/modules/ProjectManager/WorkItems/components/WorkItemContent/hooks/useGitHubIssueTimeline"; +import type { GitHubIssueInteractionConfig } from "@src/modules/ProjectManager/WorkItems/components/WorkItemContent/types"; +import { resolveGitHubIssueRemoteUrl } from "@src/modules/ProjectManager/WorkItems/githubIssueRemote"; +import { loadGitHubRemoteUrl } from "@src/modules/shared/githubIssueDetailCoordinator"; +import { useGitHubIssueDetailState } from "@src/modules/shared/hooks/useGitHubIssueDetailState"; +import { workstationIssueDetailScopeKey } from "@src/store/workstation/codeEditor/workstationIssueAtom"; + +interface RemoteResolutionState { + requestKey: string; + remoteUrl: string | null; + loading: boolean; +} + +interface WorkItemGitHubIssueState { + timeline?: { + items: GitHubIssueTimelineItem[]; + loading: boolean; + }; + interaction?: GitHubIssueInteractionConfig; +} + +interface UseWorkItemGitHubIssueStateOptions { + enabled: boolean; + repoPath: string | null; + shortId: string | null | undefined; + stateScopeKey: string; +} + +/** Hydrate a synced Work Item with the same GitHub controller as Issues pages. */ +export function useWorkItemGitHubIssueState({ + enabled, + repoPath, + shortId, + stateScopeKey, +}: UseWorkItemGitHubIssueStateOptions): WorkItemGitHubIssueState { + const store = useStore(); + const issueNumber = useMemo(() => parseGitHubIssueNumber(shortId), [shortId]); + const requestKey = + enabled && repoPath && issueNumber ? `${repoPath}:${issueNumber}` : ""; + const [remoteResolution, setRemoteResolution] = + useState(null); + const currentRemoteResolution = + remoteResolution?.requestKey === requestKey ? remoteResolution : null; + + const canonicalStateScopeKey = + repoPath && issueNumber + ? workstationIssueDetailScopeKey(repoPath, issueNumber) + : stateScopeKey; + + const detailState = useGitHubIssueDetailState({ + issueNumber: enabled ? (issueNumber ?? undefined) : undefined, + repoPath: enabled ? (repoPath ?? "") : "", + remoteUrl: currentRemoteResolution?.remoteUrl ?? undefined, + stateScopeKey: canonicalStateScopeKey, + }); + const hasMatchingIssue = + detailState.selectedState.issue?.number === issueNumber; + + useEffect(() => { + if (!requestKey || !repoPath || hasMatchingIssue) return; + + let cancelled = false; + void loadGitHubRemoteUrl(store, repoPath, () => + resolveGitHubIssueRemoteUrl(repoPath) + ) + .then((remoteUrl) => { + if (!cancelled) { + setRemoteResolution({ requestKey, remoteUrl, loading: false }); + } + }) + .catch(() => { + if (!cancelled) { + setRemoteResolution({ requestKey, remoteUrl: null, loading: false }); + } + }); + + return () => { + cancelled = true; + }; + }, [hasMatchingIssue, repoPath, requestKey, store]); + + if (!enabled || !requestKey) return {}; + if ( + currentRemoteResolution && + !currentRemoteResolution.remoteUrl && + !hasMatchingIssue + ) { + // A GitHub-shaped status without a resolvable GitHub repository must not + // surface a permanently disabled composer. Keep the local task readable; + // a later repo/data refresh can retry resolution on a new request key. + return {}; + } + + const resolvingRemote = + Boolean(requestKey) && + !hasMatchingIssue && + (currentRemoteResolution?.loading ?? true); + const waitingForIssue = Boolean( + currentRemoteResolution?.remoteUrl && !hasMatchingIssue + ); + + return { + timeline: { + items: hasMatchingIssue ? detailState.selectedState.timeline : [], + loading: + resolvingRemote || + waitingForIssue || + (hasMatchingIssue && detailState.selectedState.timelineLoading), + }, + interaction: { + ...detailState.interaction, + loading: + detailState.interaction.loading || resolvingRemote || waitingForIssue, + }, + }; +} diff --git a/src/hooks/project/index.ts b/src/hooks/project/index.ts index 148b1a9928..eff6e66769 100644 --- a/src/hooks/project/index.ts +++ b/src/hooks/project/index.ts @@ -32,6 +32,8 @@ export { useProjectDataChanged, projectDataChangedSignalAtom, projectDataChangedRepoPathAtom, + projectDataChangedChangeAtom, + type ProjectDataChange, } from "./useProjectDataChanged"; export { useAllRepoProjects } from "./useAllRepoProjects"; diff --git a/src/hooks/project/useAllRepoProjects.ts b/src/hooks/project/useAllRepoProjects.ts index efd1c63a5b..aa18206bd2 100644 --- a/src/hooks/project/useAllRepoProjects.ts +++ b/src/hooks/project/useAllRepoProjects.ts @@ -25,7 +25,7 @@ import { } from "@src/store/project/allProjectsAtom"; import { projectListRefreshAtom } from "@src/store/project/projectAtom"; -import { projectDataChangedSignalAtom } from "./useProjectDataChanged"; +import { useProjectDataChanged } from "./useProjectDataChanged"; export interface UseAllRepoProjectsReturn { entry: ProjectListEntry; @@ -34,7 +34,6 @@ export interface UseAllRepoProjectsReturn { export function useAllRepoProjects(): UseAllRepoProjectsReturn { const refreshSignal = useAtomValue(projectListRefreshAtom); - const dataChangedSignal = useAtomValue(projectDataChangedSignalAtom); const entry = useAtomValue(allProjectsEntryAtom); const updateEntry = useSetAtom(updateAllProjectsEntryAtom); const signalRef = useRef<{ cancelled: boolean }>({ cancelled: false }); @@ -84,7 +83,21 @@ export function useAllRepoProjects(): UseAllRepoProjectsReturn { return () => { signalRef.current.cancelled = true; }; - }, [loadProjects, refreshSignal, dataChangedSignal]); + }, [loadProjects, refreshSignal]); + + useProjectDataChanged( + useCallback( + (change) => { + // The global project collection contains project metadata, labels, + // and members—not Work Item payloads. A scoped item mutation is + // already handled by the owning list/detail surfaces and must not + // fan out into a read of every project plus every label/member set. + if (change?.workItemId) return; + void loadProjects(); + }, + [loadProjects] + ) + ); return { entry, diff --git a/src/hooks/project/useProjectDataChanged.test.ts b/src/hooks/project/useProjectDataChanged.test.ts new file mode 100644 index 0000000000..1fb92ad023 --- /dev/null +++ b/src/hooks/project/useProjectDataChanged.test.ts @@ -0,0 +1,54 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + invalidateProjectDataChangeCaches, + parseProjectDataChange, +} from "./useProjectDataChanged"; + +const mocks = vi.hoisted(() => ({ + invalidateProjectCache: vi.fn(), +})); + +vi.mock("@src/api/http/project", () => ({ + invalidateProjectCache: mocks.invalidateProjectCache, +})); + +describe("project data-change scoping", () => { + beforeEach(() => vi.clearAllMocks()); + + it("normalizes scoped wire payloads", () => { + expect( + parseProjectDataChange({ + project_slug: "demo", + work_item_id: "DEM-7", + repo_path: "/repos/demo", + source: "test", + }) + ).toEqual({ + projectSlug: "demo", + workItemId: "DEM-7", + repoPath: "/repos/demo", + source: "test", + }); + expect(parseProjectDataChange("legacy-payload")).toBeNull(); + }); + + it("invalidates only the project and project summaries when scoped", () => { + invalidateProjectDataChangeCaches({ + projectSlug: "demo", + workItemId: "DEM-7", + }); + + expect(mocks.invalidateProjectCache.mock.calls).toEqual([ + ["demo"], + ["__projects__"], + ]); + }); + + it("flushes safely for repo-path-only and legacy events", () => { + invalidateProjectDataChangeCaches({ repoPath: "/repos/demo" }); + invalidateProjectDataChangeCaches(null); + + expect(mocks.invalidateProjectCache.mock.calls).toEqual([[], []]); + }); +}); diff --git a/src/hooks/project/useProjectDataChanged.ts b/src/hooks/project/useProjectDataChanged.ts index 9b8a9dce40..7bcc2b16f5 100644 --- a/src/hooks/project/useProjectDataChanged.ts +++ b/src/hooks/project/useProjectDataChanged.ts @@ -20,17 +20,66 @@ import { useEffect, useRef } from "react"; import { invalidateProjectCache } from "@src/api/http/project"; +export interface ProjectDataChange { + projectSlug?: string; + workItemId?: string; + repoPath?: string; + source?: string; +} + +type ProjectDataChangedWirePayload = + | { + project_slug?: string; + work_item_id?: string; + repo_path?: string; + source?: string; + } + | string + | null; + +export function parseProjectDataChange( + payload: ProjectDataChangedWirePayload +): ProjectDataChange | null { + if (!payload || typeof payload !== "object") return null; + return { + projectSlug: payload.project_slug, + workItemId: payload.work_item_id, + repoPath: payload.repo_path, + source: payload.source, + }; +} + +export function invalidateProjectDataChangeCaches( + change: ProjectDataChange | null +): void { + if (change?.projectSlug) { + invalidateProjectCache(change.projectSlug); + // Project summaries include mutable work-item counts and timestamps. + invalidateProjectCache("__projects__"); + return; + } + // Legacy/unscoped events cannot be mapped safely to a slug. A repo path is + // not a project-cache key, so passing it here would leave relevant entries + // stale. + invalidateProjectCache(); +} + // Signal atom: bumped on every project-data-changed event. // Subscribers read this to trigger their own refresh logic. export const projectDataChangedSignalAtom = atom(0); projectDataChangedSignalAtom.debugLabel = "projectDataChangedSignalAtom"; -// Payload atom: stores the most recent repo_path from the event (if any). -// Kept as repoPath (not slug) because the Tauri event payload is a filesystem -// path matched against repo.path / repo.fs_uri in useAllRepoProjects. +// Compatibility payload for consumers that still match filesystem repos. +// New project-store consumers should use projectDataChangedChangeAtom so they +// can filter on projectSlug/workItemId without translating a repo path. export const projectDataChangedRepoPathAtom = atom(null); projectDataChangedRepoPathAtom.debugLabel = "projectDataChangedRepoPathAtom"; +export const projectDataChangedChangeAtom = atom( + null +); +projectDataChangedChangeAtom.debugLabel = "projectDataChangedChangeAtom"; + /** * Sets up the single Tauri listener for "orgii-data-changed". * Call once at the ProjectManager layout level (or app level). @@ -38,17 +87,18 @@ projectDataChangedRepoPathAtom.debugLabel = "projectDataChangedRepoPathAtom"; export function useProjectDataChangedListener(): void { const bumpSignal = useSetAtom(projectDataChangedSignalAtom); const setRepoPath = useSetAtom(projectDataChangedRepoPathAtom); + const setChange = useSetAtom(projectDataChangedChangeAtom); useEffect(() => { - const unlistenPromise = listen<{ repo_path?: string } | string>( + const unlistenPromise = listen( "orgii-data-changed", (event) => { const payload = event.payload; - const repoPath = - typeof payload === "object" ? payload?.repo_path : undefined; + const change = parseProjectDataChange(payload); - invalidateProjectCache(repoPath); - setRepoPath(repoPath ?? null); + invalidateProjectDataChangeCaches(change); + setRepoPath(change?.repoPath ?? null); + setChange(change); bumpSignal((prev) => prev + 1); } ); @@ -56,7 +106,7 @@ export function useProjectDataChangedListener(): void { return () => { unlistenPromise.then((unlisten) => unlisten()); }; - }, [bumpSignal, setRepoPath]); + }, [bumpSignal, setChange, setRepoPath]); } /** @@ -64,23 +114,29 @@ export function useProjectDataChangedListener(): void { * The callback fires after the API cache has been invalidated. */ export function useProjectDataChanged( - callback: () => void, + callback: (change: ProjectDataChange | null) => void, options?: { fireOnMount?: boolean } ): void { const signal = useAtomValue(projectDataChangedSignalAtom); + const change = useAtomValue(projectDataChangedChangeAtom); const isFirstRender = useRef(true); const callbackRef = useRef(callback); + const changeRef = useRef(change); const fireOnMount = options?.fireOnMount === true; useEffect(() => { callbackRef.current = callback; }, [callback]); + useEffect(() => { + changeRef.current = change; + }, [change]); + useEffect(() => { if (isFirstRender.current) { isFirstRender.current = false; if (!fireOnMount) return; } - callbackRef.current(); + callbackRef.current(changeRef.current); }, [signal, fireOnMount]); } diff --git a/src/modules/MainApp/TeamInbox/__tests__/useTeamInboxGitHubIssue.test.ts b/src/modules/MainApp/TeamInbox/__tests__/useTeamInboxGitHubIssue.test.ts index 989e87b69f..2f28253422 100644 --- a/src/modules/MainApp/TeamInbox/__tests__/useTeamInboxGitHubIssue.test.ts +++ b/src/modules/MainApp/TeamInbox/__tests__/useTeamInboxGitHubIssue.test.ts @@ -16,6 +16,7 @@ import type { GitHubIssue, GitHubIssueTimelineItem, } from "@src/api/tauri/github"; +import { resetGitHubIssueDetailCoordinator } from "@src/modules/shared/githubIssueDetailCoordinator"; import { type TeamInboxGitHubIssueState, @@ -26,15 +27,21 @@ const mocks = vi.hoisted(() => ({ createIssueCommentLocal: vi.fn(), getGitHubRepoPermissionsLocal: vi.fn(), getGitHubViewerLogin: vi.fn(), + getGitCredentialForRemote: vi.fn(), getIssueLocal: vi.fn(), + fetchIssue: vi.fn(), + fetchIssueTimeline: vi.fn(), listIssuesLocal: vi.fn(), listIssueTimelineLocal: vi.fn(), + listRepoAssigneesLocal: vi.fn(), updateIssueLocal: vi.fn(), })); vi.mock("@src/api/tauri/github", () => mocks); vi.mock("@src/services/git/operations/githubIssues", () => ({ + fetchIssue: mocks.fetchIssue, + fetchIssueTimeline: mocks.fetchIssueTimeline, issueCommentToTimelineItem: (comment: { id: number; body: string; @@ -197,8 +204,17 @@ describe("useTeamInboxGitHubIssue", () => { beforeEach(() => { vi.clearAllMocks(); + resetGitHubIssueDetailCoordinator(); mocks.getIssueLocal.mockResolvedValue(issue); mocks.listIssueTimelineLocal.mockResolvedValue([timelineItem]); + mocks.fetchIssue.mockResolvedValue({ data: issue }); + mocks.fetchIssueTimeline.mockResolvedValue({ data: [timelineItem] }); + mocks.getGitCredentialForRemote.mockResolvedValue({ + connection_id: "test-connection", + source: "connection", + username: "viewer", + token: "discarded-test-token", + }); mocks.getGitHubViewerLogin.mockResolvedValue("viewer"); mocks.getGitHubRepoPermissionsLocal.mockResolvedValue({ role_name: "write", diff --git a/src/modules/MainApp/TeamInbox/__tests__/useTeamInboxWorkItem.test.ts b/src/modules/MainApp/TeamInbox/__tests__/useTeamInboxWorkItem.test.ts index 926a382943..0a536ddfd5 100644 --- a/src/modules/MainApp/TeamInbox/__tests__/useTeamInboxWorkItem.test.ts +++ b/src/modules/MainApp/TeamInbox/__tests__/useTeamInboxWorkItem.test.ts @@ -187,8 +187,10 @@ describe("useTeamInboxWorkItem", () => { workItem: null, issue: "load_failed", }); - expect(mocks.readProject).not.toHaveBeenCalled(); - expect(mocks.readMembers).not.toHaveBeenCalled(); + // Context reads are independent and start in parallel so the successful + // detail path has no required-read waterfall. + expect(mocks.readProject).toHaveBeenCalledTimes(1); + expect(mocks.readMembers).toHaveBeenCalledTimes(1); }); it("serializes same-item updates so response order follows user intent", async () => { diff --git a/src/modules/MainApp/TeamInbox/useTeamInboxGitHubIssue.ts b/src/modules/MainApp/TeamInbox/useTeamInboxGitHubIssue.ts index bad8085bc9..f325e6cbf2 100644 --- a/src/modules/MainApp/TeamInbox/useTeamInboxGitHubIssue.ts +++ b/src/modules/MainApp/TeamInbox/useTeamInboxGitHubIssue.ts @@ -1,41 +1,15 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useMemo } from "react"; -import { - createIssueCommentLocal, - getGitHubRepoPermissionsLocal, - getGitHubViewerLogin, - getIssueLocal, - listIssueTimelineLocal, - listIssuesLocal, - updateIssueLocal, -} from "@src/api/tauri/github"; import type { GitHubIssue, GitHubIssueTimelineItem, - GitHubIssueUser, - GitHubRepoPermissions, } from "@src/api/tauri/github"; import type { GitHubIssueInteractionConfig, GitHubIssueStatusChangeOptions, } from "@src/modules/ProjectManager/WorkItems/components/WorkItemContent/types"; -import { issueCommentToTimelineItem } from "@src/services/git/operations/githubIssues"; - -interface GitHubIssueResolution { - key: string; - issue: GitHubIssue | null; - timeline: GitHubIssueTimelineItem[]; - viewer: GitHubIssueUser | null; - permissions: GitHubRepoPermissions | null; - duplicateCandidates: GitHubIssue[]; - duplicateCandidatesLoaded: boolean; - loadingDuplicateCandidates: boolean; - duplicateCandidatesError: boolean; - submittingComment: boolean; - updatingBody: boolean; - updatingStatus: boolean; - error: GitHubIssueInteractionConfig["error"]; -} +import { useGitHubIssueDetailState } from "@src/modules/shared/hooks/useGitHubIssueDetailState"; +import { workstationIssueDetailScopeKey } from "@src/store/workstation/codeEditor/workstationIssueAtom"; interface UseTeamInboxGitHubIssueOptions { enabled: boolean; @@ -52,41 +26,7 @@ export interface TeamInboxGitHubIssueState { interaction: GitHubIssueInteractionConfig; } -function sameLogin(left: string, right: string): boolean { - return left.trim().toLowerCase() === right.trim().toLowerCase(); -} - -function canEditIssue(resolution: GitHubIssueResolution | null): boolean { - return ( - resolution?.permissions?.can_manage_issues === true || - Boolean( - resolution?.issue && - resolution.viewer && - sameLogin(resolution.issue.user.login, resolution.viewer.login) - ) - ); -} - -function resolveViewer( - login: string, - issue: GitHubIssue | null, - timeline: GitHubIssueTimelineItem[] -): GitHubIssueUser { - const knownUsers = [ - issue?.user, - ...(issue?.assignees ?? []), - ...timeline.map((item) => item.actor), - ].filter((user): user is GitHubIssueUser => Boolean(user)); - const knownViewer = knownUsers.find((user) => sameLogin(user.login, login)); - - return ( - knownViewer ?? { - login, - avatar_url: `https://github.com/${encodeURIComponent(login)}.png?size=64`, - } - ); -} - +/** Team Inbox adapter over the canonical GitHub issue-detail controller. */ export function useTeamInboxGitHubIssue({ enabled, repoFullName, @@ -94,353 +34,42 @@ export function useTeamInboxGitHubIssue({ fallbackState, onStatusChanged, }: UseTeamInboxGitHubIssueOptions): TeamInboxGitHubIssueState { - const requestKey = - enabled && repoFullName && issueNumber - ? `${repoFullName}#${issueNumber}` - : null; - const [resolution, setResolution] = useState( - null - ); - const duplicateRequestRef = useRef<{ - key: string; - generation: number; - promise: Promise; - } | null>(null); - const requestGenerationRef = useRef(0); - const currentResolution = resolution?.key === requestKey ? resolution : null; - - useEffect(() => { - if (!requestKey || !repoFullName || !issueNumber) return; - let cancelled = false; - const generation = ++requestGenerationRef.current; - - void Promise.allSettled([ - getIssueLocal(repoFullName, issueNumber), - listIssueTimelineLocal(repoFullName, issueNumber), - getGitHubViewerLogin(), - getGitHubRepoPermissionsLocal(repoFullName), - ]).then( - ([issueResult, timelineResult, viewerResult, permissionsResult]) => { - if (cancelled || requestGenerationRef.current !== generation) return; - const issue = - issueResult.status === "fulfilled" ? issueResult.value : null; - const timeline = - timelineResult.status === "fulfilled" ? timelineResult.value : []; - const viewer = - viewerResult.status === "fulfilled" - ? resolveViewer(viewerResult.value, issue, timeline) - : null; - - setResolution({ - key: requestKey, - issue, - timeline, - viewer, - permissions: - permissionsResult.status === "fulfilled" - ? permissionsResult.value - : null, - duplicateCandidates: [], - duplicateCandidatesLoaded: false, - loadingDuplicateCandidates: false, - duplicateCandidatesError: false, - submittingComment: false, - updatingBody: false, - updatingStatus: false, - error: null, - }); - } - ); - - return () => { - cancelled = true; - if (requestGenerationRef.current === generation) { - requestGenerationRef.current += 1; - } - if (duplicateRequestRef.current?.key === requestKey) { - duplicateRequestRef.current = null; - } - }; - }, [issueNumber, repoFullName, requestKey]); - - const loadDuplicateCandidates = useCallback((): Promise => { - if (!requestKey || !repoFullName || !issueNumber || !currentResolution) { - return Promise.reject(new Error("github_duplicate_issues_unavailable")); - } - if (currentResolution.duplicateCandidatesLoaded) { - return Promise.resolve(); - } - const generation = requestGenerationRef.current; - if ( - duplicateRequestRef.current?.key === requestKey && - duplicateRequestRef.current.generation === generation - ) { - return duplicateRequestRef.current.promise; - } - - setResolution((current) => - current?.key === requestKey - ? { - ...current, - loadingDuplicateCandidates: true, - duplicateCandidatesError: false, - } - : current - ); - - const promise = listIssuesLocal(repoFullName, { - state: "all", - page: 1, - perPage: 100, - includeLinkedPullRequests: false, - }) - .then(({ issues }) => { - const candidates = issues.filter( - (candidate) => - candidate.number !== issueNumber && - typeof candidate.id === "number" && - candidate.id > 0 - ); - setResolution((current) => - current?.key === requestKey && - requestGenerationRef.current === generation - ? { - ...current, - duplicateCandidates: candidates, - duplicateCandidatesLoaded: true, - loadingDuplicateCandidates: false, - duplicateCandidatesError: false, - } - : current - ); - }) - .catch((error) => { - setResolution((current) => - current?.key === requestKey && - requestGenerationRef.current === generation - ? { - ...current, - loadingDuplicateCandidates: false, - duplicateCandidatesError: true, - } - : current - ); - throw error; - }) - .finally(() => { - if (duplicateRequestRef.current?.promise === promise) { - duplicateRequestRef.current = null; - } - }); - - duplicateRequestRef.current = { key: requestKey, generation, promise }; - return promise; - }, [currentResolution, issueNumber, repoFullName, requestKey]); - - const addComment = useCallback( - async (body: string) => { - if ( - !requestKey || - !repoFullName || - !issueNumber || - !currentResolution?.viewer || - currentResolution.submittingComment - ) { - throw new Error("github_comment_unavailable"); - } - setResolution((current) => - current?.key === requestKey - ? { ...current, submittingComment: true, error: null } - : current - ); - - try { - const comment = await createIssueCommentLocal( - repoFullName, - issueNumber, - body - ); - setResolution((current) => - current?.key === requestKey - ? { - ...current, - issue: current.issue - ? { ...current.issue, comments: current.issue.comments + 1 } - : null, - timeline: [ - ...current.timeline, - issueCommentToTimelineItem(comment), - ], - submittingComment: false, - error: null, - } - : current - ); - } catch (error) { - setResolution((current) => - current?.key === requestKey - ? { ...current, submittingComment: false, error: "comment" } - : current - ); - throw error; - } - }, - [currentResolution, issueNumber, repoFullName, requestKey] - ); - - const updateBody = useCallback( - async (body: string) => { - if ( - !requestKey || - !repoFullName || - !issueNumber || - !currentResolution || - currentResolution.updatingBody || - currentResolution.updatingStatus - ) { - throw new Error("github_body_update_unavailable"); - } - if (!canEditIssue(currentResolution)) { - throw new Error("github_body_update_forbidden"); - } - - setResolution((current) => - current?.key === requestKey - ? { ...current, updatingBody: true, error: null } - : current - ); - try { - const issue = await updateIssueLocal(repoFullName, issueNumber, { - body, - }); - setResolution((current) => - current?.key === requestKey - ? { ...current, issue, updatingBody: false, error: null } - : current - ); - } catch (error) { - setResolution((current) => - current?.key === requestKey - ? { ...current, updatingBody: false } - : current - ); - throw error; - } - }, - [currentResolution, issueNumber, repoFullName, requestKey] - ); - - const changeStatus = useCallback( - async ( - state: GitHubIssue["state"], - options?: GitHubIssueStatusChangeOptions - ) => { - if ( - !requestKey || - !repoFullName || - !issueNumber || - !currentResolution || - currentResolution.updatingStatus || - currentResolution.updatingBody - ) { - throw new Error("github_status_unavailable"); - } - const canManageStatus = canEditIssue(currentResolution); - if (!canManageStatus) { - throw new Error("github_status_forbidden"); - } - const stateReason = - state === "closed" ? (options?.stateReason ?? "completed") : undefined; - if ( - stateReason === "duplicate" && - (!options?.duplicateIssueId || options.duplicateIssueId <= 0) - ) { - throw new Error("github_duplicate_issue_required"); - } - - setResolution((current) => - current?.key === requestKey - ? { ...current, updatingStatus: true, error: null } - : current - ); - try { - const issue = await updateIssueLocal(repoFullName, issueNumber, { - state, - stateReason, - ...(stateReason === "duplicate" - ? { duplicateIssueId: options?.duplicateIssueId } - : {}), - }); - const timeline = await listIssueTimelineLocal( - repoFullName, - issueNumber - ).catch(() => currentResolution.timeline); - setResolution((current) => - current?.key === requestKey - ? { - ...current, - issue, - timeline, - updatingStatus: false, - error: null, - } - : current - ); - onStatusChanged?.(issue.state); - } catch (error) { - setResolution((current) => - current?.key === requestKey - ? { ...current, updatingStatus: false, error: "status" } - : current - ); - throw error; - } - }, - [currentResolution, issueNumber, onStatusChanged, repoFullName, requestKey] - ); + const activeIssueNumber = enabled ? issueNumber : undefined; + const activeRepo = enabled ? (repoFullName ?? "") : ""; + const detailState = useGitHubIssueDetailState({ + issueNumber: activeIssueNumber, + repoPath: activeRepo, + remoteUrl: activeRepo || undefined, + stateScopeKey: + activeRepo && activeIssueNumber + ? workstationIssueDetailScopeKey(activeRepo, activeIssueNumber) + : undefined, + }); const interaction = useMemo(() => { - const issueState = currentResolution?.issue?.state ?? fallbackState; - const viewer = currentResolution?.viewer ?? null; - const canManageStatus = canEditIssue(currentResolution); - + const canonical = detailState.interaction; return { - viewer, - issueState, - duplicateCandidates: currentResolution?.duplicateCandidates ?? [], - duplicateCandidatesLoaded: - currentResolution?.duplicateCandidatesLoaded ?? false, - loadingDuplicateCandidates: - currentResolution?.loadingDuplicateCandidates ?? false, - duplicateCandidatesError: - currentResolution?.duplicateCandidatesError ?? false, - loading: Boolean(requestKey) && !currentResolution, - canComment: Boolean(viewer), - canEditBody: canManageStatus, - canManageStatus, - submittingComment: currentResolution?.submittingComment ?? false, - updatingBody: currentResolution?.updatingBody ?? false, - updatingStatus: currentResolution?.updatingStatus ?? false, - error: currentResolution?.error ?? null, - onAddComment: addComment, - onUpdateBody: updateBody, - onLoadDuplicateCandidates: loadDuplicateCandidates, - onStatusChange: changeStatus, + ...canonical, + issueState: detailState.selectedState.issue?.state ?? fallbackState, + onStatusChange: async ( + state: GitHubIssue["state"], + options?: GitHubIssueStatusChangeOptions + ) => { + await canonical.onStatusChange(state, options); + onStatusChanged?.(state); + }, }; }, [ - addComment, - changeStatus, - currentResolution, + detailState.interaction, + detailState.selectedState.issue?.state, fallbackState, - loadDuplicateCandidates, - requestKey, - updateBody, + onStatusChanged, ]); return { - issue: currentResolution?.issue ?? null, - timeline: currentResolution?.timeline ?? [], - timelineLoading: Boolean(requestKey) && !currentResolution, + issue: detailState.selectedState.issue, + timeline: detailState.selectedState.timeline, + timelineLoading: detailState.selectedState.timelineLoading, interaction, }; } diff --git a/src/modules/MainApp/TeamInbox/useTeamInboxWorkItem.ts b/src/modules/MainApp/TeamInbox/useTeamInboxWorkItem.ts index 7592e5dded..fad4ef4bb8 100644 --- a/src/modules/MainApp/TeamInbox/useTeamInboxWorkItem.ts +++ b/src/modules/MainApp/TeamInbox/useTeamInboxWorkItem.ts @@ -87,11 +87,14 @@ export function useTeamInboxWorkItem( let cancelled = false; const request = projectId - ? projectApi.readWorkItem(projectId, workItemId).then(async (data) => { - const [projectResult, membersResult] = await Promise.allSettled([ - projectApi.readProject(projectId), - projectApi.readMembers(projectId), - ]); + ? Promise.allSettled([ + projectApi.readWorkItem(projectId, workItemId), + projectApi.readProject(projectId), + projectApi.readMembers(projectId), + ]).then(([workItemResult, projectResult, membersResult]) => { + if (workItemResult.status === "rejected") { + throw workItemResult.reason; + } const issue = projectResult.status === "rejected" || membersResult.status === "rejected" @@ -108,7 +111,7 @@ export function useTeamInboxWorkItem( ); } return { - data, + data: workItemResult.value, project: projectResult.status === "fulfilled" ? projectResult.value : null, memberEntries: diff --git a/src/modules/MainApp/WorkManagement/githubManagedItemModel.ts b/src/modules/MainApp/WorkManagement/githubManagedItemModel.ts index 8ae446ee7d..5d484ce2d5 100644 --- a/src/modules/MainApp/WorkManagement/githubManagedItemModel.ts +++ b/src/modules/MainApp/WorkManagement/githubManagedItemModel.ts @@ -24,6 +24,8 @@ export interface ManagedIssueItem { repoPath: string; remoteUrl: string; viewerLogin: string | null; + authScope?: string | null; + repoPermissions?: GitHubRepoSource["permissions"]; rawIssue: GitHubIssue; author: string; timeAgo: string; @@ -97,6 +99,8 @@ export function mapIssueToManagedItem( repoPath: source.repoPath, remoteUrl: source.remoteUrl, viewerLogin: source.viewerLogin, + authScope: source.authScope, + repoPermissions: source.permissions, rawIssue: issue, author: issue.user.login, timeAgo: formatGitHubItemTimeAgo(issue.updated_at), diff --git a/src/modules/MainApp/WorkManagement/githubWorkItemsTypes.ts b/src/modules/MainApp/WorkManagement/githubWorkItemsTypes.ts index c4cd9e966c..878dc84d1e 100644 --- a/src/modules/MainApp/WorkManagement/githubWorkItemsTypes.ts +++ b/src/modules/MainApp/WorkManagement/githubWorkItemsTypes.ts @@ -15,9 +15,13 @@ export interface GitHubRepoSource { repoFullName: string; viewerLogin: string | null; permissions: GitHubRepoPermissions | null; + authScope?: string | null; } export function getGitHubListCacheKey(source: GitHubRepoSource): string { - const identity = source.viewerLogin?.trim().toLowerCase() || "unknown-viewer"; + const identity = + source.authScope || + source.viewerLogin?.trim().toLowerCase() || + "unknown-viewer"; return `${identity}:${source.repoPath}`; } diff --git a/src/modules/MainApp/WorkManagement/useGitHubWorkItemActions.ts b/src/modules/MainApp/WorkManagement/useGitHubWorkItemActions.ts index 0a80455163..99a69defbc 100644 --- a/src/modules/MainApp/WorkManagement/useGitHubWorkItemActions.ts +++ b/src/modules/MainApp/WorkManagement/useGitHubWorkItemActions.ts @@ -5,6 +5,11 @@ import { useTranslation } from "react-i18next"; import type { GitHubIssue } from "@src/api/tauri/github"; import Message from "@src/components/Message"; import { useWorkStationTabs } from "@src/hooks/workStation/tabs"; +import { + githubIssueResourceKey, + loadGitHubIssueTimeline, + primeGitHubIssueDetailBundle, +} from "@src/modules/shared/githubIssueDetailCoordinator"; import { fetchIssueTimeline } from "@src/services/git/operations/githubIssues"; import { openGitHubIssueInChatPanelTabAtom, @@ -59,9 +64,13 @@ export function useGitHubWorkItemActions({ issue.repoPath, issue.id ); + const resourceKey = issue.authScope + ? githubIssueResourceKey(issue.authScope, issue.repo, issue.id) + : null; const selectedIssueAtom = workstationSelectedIssueAtomFamily(stateScopeKey); store.set(selectedIssueAtom, { + resourceKey, issue: issue.rawIssue, timeline: [], loading: false, @@ -76,6 +85,9 @@ export function useGitHubWorkItemActions({ repoPath: issue.repoPath, remoteUrl: issue.remoteUrl, stateScopeKey, + authScope: issue.authScope ?? undefined, + viewerLogin: issue.viewerLogin, + repoPermissions: issue.repoPermissions, }); } else { openTab( @@ -84,25 +96,50 @@ export function useGitHubWorkItemActions({ issue.title, issue.repoPath, issue.remoteUrl, - stateScopeKey + stateScopeKey, + issue.authScope ?? undefined, + issue.viewerLogin, + issue.repoPermissions ) ); } - void fetchIssueTimeline({ - remoteUrl: issue.remoteUrl, - issueNumber: issue.id, - }).then((result) => { - store.set(selectedIssueAtom, (current) => { - if (current.issue?.html_url !== issue.rawIssue.html_url) - return current; - return { - ...current, - timeline: result.data ?? [], - timelineLoading: false, - error: result.error ?? null, - }; + if (!resourceKey) return; + void loadGitHubIssueTimeline(store, resourceKey, async () => { + const result = await fetchIssueTimeline({ + remoteUrl: issue.remoteUrl, + issueNumber: issue.id, + }); + if (result.error) throw new Error(result.error); + return result.data ?? []; + }) + .then((timeline) => { + store.set(selectedIssueAtom, (current) => { + if (current.issue?.html_url !== issue.rawIssue.html_url) + return current; + return { + ...current, + timeline, + timelineLoading: false, + error: null, + }; + }); + primeGitHubIssueDetailBundle(store, resourceKey, { + issue: issue.rawIssue, + timeline, + error: null, + }); + }) + .catch((error: unknown) => { + store.set(selectedIssueAtom, (current) => + current.resourceKey === resourceKey + ? { + ...current, + timelineLoading: false, + error: error instanceof Error ? error.message : String(error), + } + : current + ); }); - }); }, [detailHost, openIssueInChatPanel, openTab, store] ); diff --git a/src/modules/MainApp/WorkManagement/useGitHubWorkItemsLoadLifecycle.ts b/src/modules/MainApp/WorkManagement/useGitHubWorkItemsLoadLifecycle.ts index d6ca7ed587..591f25a514 100644 --- a/src/modules/MainApp/WorkManagement/useGitHubWorkItemsLoadLifecycle.ts +++ b/src/modules/MainApp/WorkManagement/useGitHubWorkItemsLoadLifecycle.ts @@ -16,6 +16,10 @@ import type { OpenPRItem, PullRequestListState, } from "@src/api/tauri/github"; +import { + loadGitHubDetailAuthScope, + loadGitHubViewer, +} from "@src/modules/shared/githubIssueDetailCoordinator"; import { GITHUB_LIST_CACHE_TTL_MS, coalesceGitHubListRequest, @@ -291,6 +295,7 @@ async function resolveGitHubRepoSource( repoFullName, viewerLogin: null, permissions: null, + authScope: null, }; } @@ -518,14 +523,22 @@ export function useGitHubWorkItemsLoadLifecycle({ setLoading(false); return; } - const [viewerResult, sources] = await Promise.all([ - coalesceGitHubListRequest( - "work-management:viewer-login", + const authScopePromise = loadGitHubDetailAuthScope(store).catch( + () => null + ); + const viewerResultPromise = authScopePromise.then((authScope) => + loadGitHubViewer( + store, + authScope ?? "github.com:unresolved", getGitHubViewerLogin ).then( (login) => ({ login, error: null }), (error: unknown) => ({ login: null, error: String(error) }) - ), + ) + ); + const [authScope, viewerResult, sources] = await Promise.all([ + authScopePromise, + viewerResultPromise, mapWithConcurrency( gitRepos, GITHUB_SOURCE_CONCURRENCY, @@ -536,7 +549,11 @@ export function useGitHubWorkItemsLoadLifecycle({ const viewerLoginError = viewerResult.error; const resolvedSources = sources .filter((source): source is GitHubRepoSource => Boolean(source)) - .map((source) => ({ ...source, viewerLogin: viewerResult.login })); + .map((source) => ({ + ...source, + viewerLogin: viewerResult.login, + authScope, + })); if (cancelled) return; if (!viewerResult.login) { permissionViewerRef.current = null; diff --git a/src/modules/ProjectManager/WorkItems/components/CreateWorkItemView/index.tsx b/src/modules/ProjectManager/WorkItems/components/CreateWorkItemView/index.tsx index 101bb516f0..ec0691bfb8 100644 --- a/src/modules/ProjectManager/WorkItems/components/CreateWorkItemView/index.tsx +++ b/src/modules/ProjectManager/WorkItems/components/CreateWorkItemView/index.tsx @@ -236,7 +236,11 @@ const CreateWorkItemView: React.FC = ({ selectedProjectSlug: inlineFields.selectedProjectSlug, }); - await emit("orgii-data-changed"); + await emit("orgii-data-changed", { + project_slug: result.projectSlug, + work_item_id: result.shortId, + source: "work-item-create", + }); if (createMore) { inlineFields.resetDraftForCreateMore(); onWorkItemCreated(result); diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/WorkItemDescriptionEditing.test.ts b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/WorkItemDescriptionEditing.test.ts index eba67024ed..6d2c2635bd 100644 --- a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/WorkItemDescriptionEditing.test.ts +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/WorkItemDescriptionEditing.test.ts @@ -958,6 +958,32 @@ describe("WorkItemContent description editing", () => { ).toBeNull(); }); + it("does not restore dead local Discussion navigation while GitHub state hydrates", () => { + act(() => { + root.render( + createElement(WorkItemContent, { + workItem: { + ...baseWorkItem, + status: "open", + workItemStatus: "open", + }, + presentation: "thread", + }) + ); + }); + + expect( + container.querySelector( + "[data-testid='work-item-thread-secondary-navigation']" + ) + ).toBeNull(); + expect( + container.querySelector( + "[data-testid='work-item-thread-open-discussion']" + ) + ).toBeNull(); + }); + it("keeps the current secondary view on refresh and resets on item switch", () => { const renderThread = (workItem: WorkItem) => root.render( diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/useGitHubIssueTimeline.test.ts b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/useGitHubIssueTimeline.test.ts index 60b898a5d7..b2a357983b 100644 --- a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/useGitHubIssueTimeline.test.ts +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/useGitHubIssueTimeline.test.ts @@ -13,6 +13,7 @@ import { } from "vitest"; import type { GitHubIssueTimelineItem } from "@src/api/tauri/github"; +import { resetGitHubIssueDetailCoordinator } from "@src/modules/shared/githubIssueDetailCoordinator"; import { parseGitHubIssueNumber, @@ -21,6 +22,7 @@ import { const mocks = vi.hoisted(() => ({ getGitRemotes: vi.fn(), + getGitCredentialForRemote: vi.fn(), fetchIssueTimeline: vi.fn(), })); @@ -28,6 +30,10 @@ vi.mock("@src/api/http/git/remotes", () => ({ getGitRemotes: mocks.getGitRemotes, })); +vi.mock("@src/api/tauri/github", () => ({ + getGitCredentialForRemote: mocks.getGitCredentialForRemote, +})); + vi.mock("@src/services/git/operations/githubIssues", () => ({ fetchIssueTimeline: mocks.fetchIssueTimeline, })); @@ -79,7 +85,15 @@ describe("useGitHubIssueTimeline", () => { }); beforeEach(() => { + resetGitHubIssueDetailCoordinator(); mocks.getGitRemotes.mockReset(); + mocks.getGitCredentialForRemote.mockReset(); + mocks.getGitCredentialForRemote.mockResolvedValue({ + connection_id: "test-connection", + source: "connection", + username: "ada", + token: "discarded-test-token", + }); mocks.fetchIssueTimeline.mockReset(); container = document.createElement("div"); document.body.appendChild(container); diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/hooks/useGitHubIssueTimeline.ts b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/hooks/useGitHubIssueTimeline.ts index dc1c8cdabd..97e7150d12 100644 --- a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/hooks/useGitHubIssueTimeline.ts +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/hooks/useGitHubIssueTimeline.ts @@ -1,10 +1,18 @@ +import { useStore } from "jotai"; import { useEffect, useMemo, useState } from "react"; -import { getGitRemotes } from "@src/api/http/git/remotes"; import type { GitHubIssueTimelineItem } from "@src/api/tauri/github"; +import { + githubIssueResourceKey, + loadGitHubDetailAuthScope, + loadGitHubIssueTimeline, + loadGitHubRemoteUrl, +} from "@src/modules/shared/githubIssueDetailCoordinator"; import { parseGithubRepoFullName } from "@src/services/git/operations/createPullRequest"; import { fetchIssueTimeline } from "@src/services/git/operations/githubIssues"; +import { resolveGitHubIssueRemoteUrl } from "../../../githubIssueRemote"; + interface GitHubIssueTimelineState { requestKey: string; timeline: GitHubIssueTimelineItem[]; @@ -30,24 +38,6 @@ export function parseGitHubIssueNumber( : null; } -async function resolveRemoteUrl(repoPath: string): Promise { - if (parseGithubRepoFullName(repoPath)) return repoPath; - - const remotes = await getGitRemotes({ - repo_id: "default", - repo_path: repoPath, - }); - const origin = remotes?.remotes?.find((remote) => remote.name === "origin"); - const fallback = remotes?.remotes?.[0]; - return ( - origin?.url || - origin?.fetch_url || - fallback?.url || - fallback?.fetch_url || - null - ); -} - export function useGitHubIssueTimeline({ enabled, repoPath, @@ -57,6 +47,7 @@ export function useGitHubIssueTimeline({ timelineLoading: boolean; timelineError: string | null; } { + const store = useStore(); const issueNumber = useMemo(() => parseGitHubIssueNumber(shortId), [shortId]); const requestKey = enabled && repoPath && issueNumber ? `${repoPath}:${issueNumber}` : ""; @@ -69,7 +60,12 @@ export function useGitHubIssueTimeline({ let cancelled = false; void (async () => { - const remoteUrl = await resolveRemoteUrl(repoPath); + const [remoteUrl, authScope] = await Promise.all([ + loadGitHubRemoteUrl(store, repoPath, () => + resolveGitHubIssueRemoteUrl(repoPath) + ), + loadGitHubDetailAuthScope(store), + ]); if (!remoteUrl) { if (!cancelled) { setState({ @@ -82,21 +78,47 @@ export function useGitHubIssueTimeline({ return; } - const result = await fetchIssueTimeline({ remoteUrl, issueNumber }); + const repoFullName = parseGithubRepoFullName(remoteUrl); + if (!repoFullName) { + throw new Error("no_github_remote"); + } + const resourceKey = githubIssueResourceKey( + authScope, + repoFullName, + issueNumber + ); + const timeline = await loadGitHubIssueTimeline( + store, + resourceKey, + async () => { + const result = await fetchIssueTimeline({ remoteUrl, issueNumber }); + if (result.error) throw new Error(result.error); + return result.data ?? []; + } + ); + if (!cancelled) { + setState({ + requestKey, + timeline, + loading: false, + error: null, + }); + } + })().catch((error: unknown) => { if (!cancelled) { setState({ requestKey, - timeline: result.data ?? [], + timeline: [], loading: false, - error: result.error ?? null, + error: error instanceof Error ? error.message : String(error), }); } - })(); + }); return () => { cancelled = true; }; - }, [issueNumber, repoPath, requestKey]); + }, [issueNumber, repoPath, requestKey, store]); return { timeline: currentState?.timeline ?? [], diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/index.tsx b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/index.tsx index 74ee28b23f..7253c51f6b 100644 --- a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/index.tsx +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/index.tsx @@ -764,7 +764,7 @@ const WorkItemContent: React.FC = ({ {todosSection} {threadLowerSection} - {!isGitHubWorkItem || !githubIssueInteraction ? ( + {!isGitHubWorkItem ? ( diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/presentation.ts b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/presentation.ts index 3ee6a88ba4..af3c437fe2 100644 --- a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/presentation.ts +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/presentation.ts @@ -11,8 +11,9 @@ export interface WorkItemContentSectionPolicy { * Keep the Work Item presentation policy explicit and testable. * * The default surface retains its existing tabs/table. Team Inbox uses the - * thread policy: workflow/session cards stay in Overview, Discussion is a - * drill-in view, and the duplicate linked-session table is absent. + * thread policy: workflow/session cards stay in Overview, local Discussion is + * a drill-in view, and the duplicate linked-session table is absent. GitHub + * issues supply their native floating comment composer separately. */ export function resolveWorkItemContentSectionPolicy( presentation: WorkItemContentPresentation, diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/types.ts b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/types.ts index bed9ddd500..ba726141e9 100644 --- a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/types.ts +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/types.ts @@ -23,8 +23,9 @@ export type SessionTab = (typeof SESSION_TAB_KEYS)[number]; export interface WorkItemContentProps { workItem: WorkItemExtended; /** - * `thread` presents the task as the primary view and Discussion as a drill-in. - * It omits the legacy lower tab strip and linked-session table. + * `thread` presents the task as the primary view. Local Work Items retain + * Discussion as a drill-in; GitHub issues use their floating comment composer. + * The presentation omits the legacy lower tab strip and linked-session table. */ presentation?: WorkItemContentPresentation; onUpdateWorkItem?: (updates: Partial) => void; diff --git a/src/modules/ProjectManager/WorkItems/githubIssueRemote.ts b/src/modules/ProjectManager/WorkItems/githubIssueRemote.ts new file mode 100644 index 0000000000..df322dec73 --- /dev/null +++ b/src/modules/ProjectManager/WorkItems/githubIssueRemote.ts @@ -0,0 +1,28 @@ +import { getGitRemotes } from "@src/api/http/git/remotes"; +import { parseGithubRepoFullName } from "@src/services/git/operations/createPullRequest"; + +/** Resolve a GitHub remote from either a remote URL/repository name or a local checkout. */ +export async function resolveGitHubIssueRemoteUrl( + repoPath: string +): Promise { + if ( + /^[^/:@\s]+\/[^/\s]+$/.test(repoPath) || + parseGithubRepoFullName(repoPath) + ) { + return repoPath; + } + + const remotes = await getGitRemotes({ + repo_id: "default", + repo_path: repoPath, + }); + const origin = remotes?.remotes?.find((remote) => remote.name === "origin"); + const fallback = remotes?.remotes?.[0]; + return ( + origin?.url || + origin?.fetch_url || + fallback?.url || + fallback?.fetch_url || + null + ); +} diff --git a/src/modules/ProjectManager/WorkItems/hooks/useMultiSelect.ts b/src/modules/ProjectManager/WorkItems/hooks/useMultiSelect.ts index 4070034fc7..ad869d0013 100644 --- a/src/modules/ProjectManager/WorkItems/hooks/useMultiSelect.ts +++ b/src/modules/ProjectManager/WorkItems/hooks/useMultiSelect.ts @@ -89,7 +89,10 @@ export function useMultiSelect({ `Batch deleted ${result.deleted.length} work items (${result.errors.length} errors)` ); - await emit("orgii-data-changed"); + await emit("orgii-data-changed", { + project_slug: projectSlug, + source: "work-items-batch-delete", + }); onBatchDeleteComplete?.(); setSelectedIds(new Set()); return; @@ -100,7 +103,10 @@ export function useMultiSelect({ for (const workItemId of ids) { await onDelete(workItemId); } - await emit("orgii-data-changed"); + await emit("orgii-data-changed", { + project_slug: projectSlug ?? undefined, + source: "work-items-batch-delete-fallback", + }); setSelectedIds(new Set()); } catch (err) { logger.error("Batch delete failed:", err); diff --git a/src/modules/ProjectManager/WorkItems/hooks/useProjectData/useProjectData.ts b/src/modules/ProjectManager/WorkItems/hooks/useProjectData/useProjectData.ts index ab3486e346..559d744e09 100644 --- a/src/modules/ProjectManager/WorkItems/hooks/useProjectData/useProjectData.ts +++ b/src/modules/ProjectManager/WorkItems/hooks/useProjectData/useProjectData.ts @@ -169,10 +169,25 @@ export function useProjectData( }; }, [autoLoad, isActive, loadFromFiles, selectedProjectId]); - const activeLoadFromFiles = useCallback(() => { - if (!isActive) return; - loadFromFiles(); - }, [isActive, loadFromFiles]); + const activeLoadFromFiles = useCallback( + ( + change?: { + projectSlug?: string; + } | null + ) => { + if (!isActive) return; + const activeProjectSlug = projectRef.current?.slug; + if ( + change?.projectSlug && + activeProjectSlug && + change.projectSlug !== activeProjectSlug + ) { + return; + } + loadFromFiles(); + }, + [isActive, loadFromFiles] + ); useProjectDataChanged(activeLoadFromFiles); diff --git a/src/modules/ProjectManager/WorkItems/hooks/useWorkItemOrchestrator/useAutoReview.ts b/src/modules/ProjectManager/WorkItems/hooks/useWorkItemOrchestrator/useAutoReview.ts index ece38bb902..34ed5273b5 100644 --- a/src/modules/ProjectManager/WorkItems/hooks/useWorkItemOrchestrator/useAutoReview.ts +++ b/src/modules/ProjectManager/WorkItems/hooks/useWorkItemOrchestrator/useAutoReview.ts @@ -18,6 +18,9 @@ import type { AgentRole } from "../../constants"; const logger = createLogger("useAutoReview"); interface UseAutoReviewOptions { + enabled: boolean; + claimLaunch: () => boolean; + releaseLaunchClaim: () => void; workItem: WorkItemExtended; projectRepoPath: string | null; accountId: string | null; @@ -41,6 +44,9 @@ interface UseAutoReviewOptions { */ export function useAutoReview(options: UseAutoReviewOptions): void { const { + enabled, + claimLaunch, + releaseLaunchClaim, workItem, projectRepoPath, accountId, @@ -63,8 +69,13 @@ export function useAutoReview(options: UseAutoReviewOptions): void { useEffect(() => { const phase = workItem.orchestratorState?.current_phase; + if (!enabled) { + reviewLaunchRef.current = false; + return; + } if (phase !== ORCHESTRATOR_PHASE.Review) { reviewLaunchRef.current = false; + releaseLaunchClaim(); return; } if (reviewLaunchRef.current || isStartingAgentRef.current) return; @@ -82,6 +93,7 @@ export function useAutoReview(options: UseAutoReviewOptions): void { // retry on the next render when params become available. if (!projectRepoPath || !accountId || !shortId) return; + if (!claimLaunch()) return; reviewLaunchRef.current = true; let cancelled = false; @@ -136,6 +148,9 @@ export function useAutoReview(options: UseAutoReviewOptions): void { }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [ + enabled, + claimLaunch, + releaseLaunchClaim, workItem.orchestratorState?.current_phase, workItem.linkedSessions, workItem.name, diff --git a/src/modules/ProjectManager/WorkItems/hooks/useWorkItemOrchestrator/useStaleSessionDetection.ts b/src/modules/ProjectManager/WorkItems/hooks/useWorkItemOrchestrator/useStaleSessionDetection.ts index ba644d9b08..68cdc03b0c 100644 --- a/src/modules/ProjectManager/WorkItems/hooks/useWorkItemOrchestrator/useStaleSessionDetection.ts +++ b/src/modules/ProjectManager/WorkItems/hooks/useWorkItemOrchestrator/useStaleSessionDetection.ts @@ -17,6 +17,7 @@ const logger = createLogger("useStaleSessionDetection"); const STALE_GRACE_PERIOD_MS = 5000; interface UseStaleSessionDetectionOptions { + enabled: boolean; workItem: WorkItemExtended; projectRepoPath: string | null; projectSlug: string | null; @@ -39,6 +40,7 @@ export function useStaleSessionDetection( options: UseStaleSessionDetectionOptions ): void { const { + enabled, workItem, projectRepoPath, projectSlug, @@ -50,6 +52,10 @@ export function useStaleSessionDetection( const syncCheckDoneRef = useRef(null); useEffect(() => { + if (!enabled) { + syncCheckDoneRef.current = null; + return; + } if (!projectRepoPath || !projectSlug || !shortId) return; if (syncCheckDoneRef.current === workItem.session_id) return; syncCheckDoneRef.current = workItem.session_id; @@ -86,6 +92,7 @@ export function useStaleSessionDetection( cancelled = true; }; }, [ + enabled, workItem.session_id, workItem.orchestratorState?.current_phase, projectRepoPath, @@ -145,6 +152,10 @@ export function useStaleSessionDetection( const staleCheckDoneRef = useRef(null); useEffect(() => { + if (!enabled) { + staleCheckDoneRef.current = null; + return; + } if (isStartingAgent) return; const phase = workItem.orchestratorState?.current_phase; @@ -218,6 +229,7 @@ export function useStaleSessionDetection( cancelledRef.current = true; }; }, [ + enabled, workItem.orchestratorState?.current_phase, workItem.linkedSessions, linkedSessionKey, diff --git a/src/modules/ProjectManager/WorkItems/hooks/useWorkItemOrchestrator/useWorkItemCollabLock.test.ts b/src/modules/ProjectManager/WorkItems/hooks/useWorkItemOrchestrator/useWorkItemCollabLock.test.ts index 2a014ebc4a..2d892c6917 100644 --- a/src/modules/ProjectManager/WorkItems/hooks/useWorkItemOrchestrator/useWorkItemCollabLock.test.ts +++ b/src/modules/ProjectManager/WorkItems/hooks/useWorkItemOrchestrator/useWorkItemCollabLock.test.ts @@ -25,6 +25,7 @@ import { import type { ResolvedCollabOrgResolution } from "./useWorkItemCollabLock"; import { + getSharedCollabResolutionWatchCount, resolveLockHolder, watchCollabOrgResolution, } from "./useWorkItemCollabLock"; @@ -109,6 +110,28 @@ describe("resolveLockHolder", () => { }); describe("watchCollabOrgResolution", () => { + it("shares one project probe across concurrent detail surfaces", async () => { + const first: ResolvedCollabOrgResolution[] = []; + const second: ResolvedCollabOrgResolution[] = []; + const releaseFirst = watchCollabOrgResolution("proj-shared", (resolution) => + first.push(resolution) + ); + const releaseSecond = watchCollabOrgResolution( + "proj-shared", + (resolution) => second.push(resolution) + ); + + await settle(); + expect(projectApiMock.readProject).toHaveBeenCalledOnce(); + expect(first.at(-1)).toEqual(second.at(-1)); + expect(getSharedCollabResolutionWatchCount()).toBe(1); + + releaseFirst(); + expect(getSharedCollabResolutionWatchCount()).toBe(1); + releaseSecond(); + expect(getSharedCollabResolutionWatchCount()).toBe(0); + }); + it("re-resolves the cloud alias when org2CloudOrgsAtom hydrates LATE", async () => { const resolutions: ResolvedCollabOrgResolution[] = []; const unwatch = watchCollabOrgResolution("proj-1", (resolution) => diff --git a/src/modules/ProjectManager/WorkItems/hooks/useWorkItemOrchestrator/useWorkItemCollabLock.ts b/src/modules/ProjectManager/WorkItems/hooks/useWorkItemOrchestrator/useWorkItemCollabLock.ts index e696f5e8d6..c76679ebaf 100644 --- a/src/modules/ProjectManager/WorkItems/hooks/useWorkItemOrchestrator/useWorkItemCollabLock.ts +++ b/src/modules/ProjectManager/WorkItems/hooks/useWorkItemOrchestrator/useWorkItemCollabLock.ts @@ -98,7 +98,7 @@ const UNRESOLVED: CollabOrgResolution = { status: "unresolved" }; * unsubscribe function. Exported for the node vitest env (the repo has no * hook-render harness) — the hook's effect is a thin binding over this. */ -export function watchCollabOrgResolution( +function startCollabOrgResolutionWatch( projectSlug: string, onResolution: (resolution: ResolvedCollabOrgResolution) => void ): () => void { @@ -164,6 +164,67 @@ export function watchCollabOrgResolution( }; } +interface SharedCollabResolutionWatch { + listeners: Set<(resolution: ResolvedCollabOrgResolution) => void>; + lastResolution: ResolvedCollabOrgResolution | null; + stop: () => void; +} + +const sharedCollabResolutionWatches = new Map< + string, + SharedCollabResolutionWatch +>(); + +/** + * Multicast one project-org probe across every rendered presentation of the + * same work item. Entries exist only while at least one consumer is mounted, + * so repeated tab open/close cycles cannot retain project state. + */ +export function watchCollabOrgResolution( + projectSlug: string, + onResolution: (resolution: ResolvedCollabOrgResolution) => void +): () => void { + let shared = sharedCollabResolutionWatches.get(projectSlug); + if (!shared) { + shared = { + listeners: new Set(), + lastResolution: null, + stop: () => undefined, + }; + sharedCollabResolutionWatches.set(projectSlug, shared); + const ownedShared = shared; + shared.stop = startCollabOrgResolutionWatch(projectSlug, (resolution) => { + ownedShared.lastResolution = resolution; + for (const listener of ownedShared.listeners) listener(resolution); + }); + } + + shared.listeners.add(onResolution); + if (shared.lastResolution) { + const currentResolution = shared.lastResolution; + queueMicrotask(() => { + if (shared?.listeners.has(onResolution)) onResolution(currentResolution); + }); + } + + let released = false; + return () => { + if (released) return; + released = true; + const current = sharedCollabResolutionWatches.get(projectSlug); + if (!current) return; + current.listeners.delete(onResolution); + if (current.listeners.size === 0) { + current.stop(); + sharedCollabResolutionWatches.delete(projectSlug); + } + }; +} + +export function getSharedCollabResolutionWatchCount(): number { + return sharedCollabResolutionWatches.size; +} + interface LockHolderDisplay { heldByOther: boolean; holderName: string | null; diff --git a/src/modules/ProjectManager/WorkItems/hooks/useWorkItemOrchestrator/useWorkItemOrchestrator.ts b/src/modules/ProjectManager/WorkItems/hooks/useWorkItemOrchestrator/useWorkItemOrchestrator.ts index c3ea97e857..76272463b1 100644 --- a/src/modules/ProjectManager/WorkItems/hooks/useWorkItemOrchestrator/useWorkItemOrchestrator.ts +++ b/src/modules/ProjectManager/WorkItems/hooks/useWorkItemOrchestrator/useWorkItemOrchestrator.ts @@ -1,4 +1,4 @@ -import { useAtomValue } from "jotai"; +import { useAtomValue, useStore } from "jotai"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -26,6 +26,11 @@ import { isCollabMembershipUnresolvedError, useWorkItemCollabLock, } from "./useWorkItemCollabLock"; +import { + claimWorkItemOrchestratorAction, + releaseWorkItemOrchestratorAction, + useWorkItemOrchestratorOwnership, +} from "./workItemOrchestratorOwnership"; const logger = createLogger("useWorkItemOrchestrator"); const RUNNING_LINKED_SESSION_STATUS = "running" as const; @@ -76,6 +81,37 @@ export function useWorkItemOrchestrator( } = options; const { t } = useTranslation("projects"); + const store = useStore(); + const ownershipKey = + projectSlug && shortId + ? `${projectSlug}:${shortId}:${workItem.session_id}` + : null; + const ownsAutomaticSideEffects = useWorkItemOrchestratorOwnership( + store, + ownershipKey + ); + const claimAutoReviewLaunch = useCallback( + () => + Boolean( + ownershipKey && + ownsAutomaticSideEffects && + claimWorkItemOrchestratorAction( + store, + ownershipKey, + "auto-review-launch" + ) + ), + [ownershipKey, ownsAutomaticSideEffects, store] + ); + const releaseAutoReviewLaunchClaim = useCallback(() => { + if (ownershipKey) { + releaseWorkItemOrchestratorAction( + store, + ownershipKey, + "auto-review-launch" + ); + } + }, [ownershipKey, store]); const [isStartingAgent, setIsStartingAgent] = useState(false); const [activeAgentSessionId, setActiveAgentSessionId] = useState< @@ -420,7 +456,7 @@ export function useWorkItemOrchestrator( // §16.6). The Rust side clears the LOCAL execution_lock; this drops // the collab holder so teammates can start next (best-effort — the // synced payload also reconciles it). - if (wasActive) { + if (wasActive && ownsAutomaticSideEffects) { void collabLock.releaseLock(); } } @@ -430,9 +466,13 @@ export function useWorkItemOrchestrator( activeAgentSessionId, persistedActiveSessionId, collabLock, + ownsAutomaticSideEffects, ]); useAutoReview({ + enabled: ownsAutomaticSideEffects, + claimLaunch: claimAutoReviewLaunch, + releaseLaunchClaim: releaseAutoReviewLaunchClaim, workItem, projectRepoPath, accountId, @@ -448,6 +488,7 @@ export function useWorkItemOrchestrator( }); useStaleSessionDetection({ + enabled: ownsAutomaticSideEffects, workItem, projectRepoPath, projectSlug: projectSlug ?? null, diff --git a/src/modules/ProjectManager/WorkItems/hooks/useWorkItemOrchestrator/workItemOrchestratorOwnership.test.ts b/src/modules/ProjectManager/WorkItems/hooks/useWorkItemOrchestrator/workItemOrchestratorOwnership.test.ts new file mode 100644 index 0000000000..57d87500c5 --- /dev/null +++ b/src/modules/ProjectManager/WorkItems/hooks/useWorkItemOrchestrator/workItemOrchestratorOwnership.test.ts @@ -0,0 +1,152 @@ +// @vitest-environment jsdom +import { Provider, createStore } from "jotai"; +import { act, createElement } from "react"; +import { createRoot } from "react-dom/client"; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +import { + claimWorkItemOrchestratorAction, + getWorkItemOrchestratorOwnershipCount, + isWorkItemOrchestratorOwner, + releaseWorkItemOrchestratorAction, + resetWorkItemOrchestratorOwnership, + retainWorkItemOrchestratorOwnership, + useWorkItemOrchestratorOwnership, +} from "./workItemOrchestratorOwnership"; + +function OwnershipProbe({ + label, + ownershipKey, + store, +}: { + label: string; + ownershipKey: string; + store: ReturnType; +}) { + const ownsSideEffects = useWorkItemOrchestratorOwnership(store, ownershipKey); + return createElement("div", { + "data-testid": label, + "data-owns-side-effects": String(ownsSideEffects), + }); +} + +describe("workItemOrchestratorOwnership", () => { + const actEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + }; + + beforeAll(() => { + actEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + }); + + beforeEach(() => resetWorkItemOrchestratorOwnership()); + + afterAll(() => { + Reflect.deleteProperty(actEnvironment, "IS_REACT_ACT_ENVIRONMENT"); + }); + + it("elects one owner, hands off on release, and drops the final entry", () => { + const store = createStore(); + const first = Symbol("first"); + const second = Symbol("second"); + const firstListener = vi.fn(); + const secondListener = vi.fn(); + + const releaseFirst = retainWorkItemOrchestratorOwnership( + store, + "project:item", + first, + firstListener + ); + const releaseSecond = retainWorkItemOrchestratorOwnership( + store, + "project:item", + second, + secondListener + ); + + expect(isWorkItemOrchestratorOwner(store, "project:item", first)).toBe( + true + ); + expect(isWorkItemOrchestratorOwner(store, "project:item", second)).toBe( + false + ); + expect(getWorkItemOrchestratorOwnershipCount(store)).toBe(1); + expect( + claimWorkItemOrchestratorAction(store, "project:item", "auto-review") + ).toBe(true); + expect( + claimWorkItemOrchestratorAction(store, "project:item", "auto-review") + ).toBe(false); + + releaseFirst(); + expect(isWorkItemOrchestratorOwner(store, "project:item", second)).toBe( + true + ); + expect(secondListener).toHaveBeenCalledOnce(); + expect( + claimWorkItemOrchestratorAction(store, "project:item", "auto-review") + ).toBe(false); + releaseWorkItemOrchestratorAction(store, "project:item", "auto-review"); + expect( + claimWorkItemOrchestratorAction(store, "project:item", "auto-review") + ).toBe(true); + + releaseSecond(); + expect(getWorkItemOrchestratorOwnershipCount(store)).toBe(0); + }); + + it("hands hook ownership to the remaining rendered surface", async () => { + const store = createStore(); + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const ownershipKey = "project:item:session"; + const probe = (label: string) => + createElement(OwnershipProbe, { + key: label, + label, + ownershipKey, + store, + }); + + await act(async () => { + root.render( + createElement(Provider, { store }, probe("first"), probe("second")) + ); + }); + expect( + container + .querySelector("[data-testid='first']") + ?.getAttribute("data-owns-side-effects") + ).toBe("true"); + expect( + container + .querySelector("[data-testid='second']") + ?.getAttribute("data-owns-side-effects") + ).toBe("false"); + + await act(async () => { + root.render(createElement(Provider, { store }, probe("second"))); + }); + await vi.waitFor(() => { + expect( + container + .querySelector("[data-testid='second']") + ?.getAttribute("data-owns-side-effects") + ).toBe("true"); + }); + + act(() => root.unmount()); + container.remove(); + expect(getWorkItemOrchestratorOwnershipCount(store)).toBe(0); + }); +}); diff --git a/src/modules/ProjectManager/WorkItems/hooks/useWorkItemOrchestrator/workItemOrchestratorOwnership.ts b/src/modules/ProjectManager/WorkItems/hooks/useWorkItemOrchestrator/workItemOrchestratorOwnership.ts new file mode 100644 index 0000000000..a9ef69b8ef --- /dev/null +++ b/src/modules/ProjectManager/WorkItems/hooks/useWorkItemOrchestrator/workItemOrchestratorOwnership.ts @@ -0,0 +1,116 @@ +import type { Store } from "jotai/vanilla/store"; +import { useCallback, useRef, useSyncExternalStore } from "react"; + +interface OwnershipEntry { + claims: Set; + owners: symbol[]; + listeners: Map void>; +} + +let entriesByStore = new WeakMap>(); + +function entriesFor(store: Store): Map { + let entries = entriesByStore.get(store); + if (!entries) { + entries = new Map(); + entriesByStore.set(store, entries); + } + return entries; +} + +function notify(entry: OwnershipEntry): void { + for (const listener of entry.listeners.values()) listener(); +} + +export function retainWorkItemOrchestratorOwnership( + store: Store, + key: string, + owner: symbol, + listener: () => void +): () => void { + const entries = entriesFor(store); + let entry = entries.get(key); + if (!entry) { + entry = { claims: new Set(), owners: [], listeners: new Map() }; + entries.set(key, entry); + } + if (!entry.owners.includes(owner)) entry.owners.push(owner); + entry.listeners.set(owner, listener); + + let released = false; + return () => { + if (released) return; + released = true; + const current = entries.get(key); + if (!current) return; + current.owners = current.owners.filter((candidate) => candidate !== owner); + current.listeners.delete(owner); + if (current.owners.length === 0) { + entries.delete(key); + if (entries.size === 0) entriesByStore.delete(store); + return; + } + notify(current); + }; +} + +export function isWorkItemOrchestratorOwner( + store: Store, + key: string, + owner: symbol +): boolean { + return entriesByStore.get(store)?.get(key)?.owners[0] === owner; +} + +export function getWorkItemOrchestratorOwnershipCount(store: Store): number { + return entriesByStore.get(store)?.size ?? 0; +} + +export function claimWorkItemOrchestratorAction( + store: Store, + key: string, + action: string +): boolean { + const entry = entriesByStore.get(store)?.get(key); + if (!entry || entry.claims.has(action)) return false; + entry.claims.add(action); + return true; +} + +export function releaseWorkItemOrchestratorAction( + store: Store, + key: string, + action: string +): void { + entriesByStore.get(store)?.get(key)?.claims.delete(action); +} + +export function resetWorkItemOrchestratorOwnership(): void { + entriesByStore = new WeakMap>(); +} + +export function useWorkItemOrchestratorOwnership( + store: Store, + key: string | null +): boolean { + const ownerRef = useRef(Symbol("work-item-orchestrator-owner")); + const subscribe = useCallback( + (listener: () => void) => { + if (!key) return () => undefined; + return retainWorkItemOrchestratorOwnership( + store, + key, + ownerRef.current, + listener + ); + }, + [key, store] + ); + const getSnapshot = useCallback( + () => + key ? isWorkItemOrchestratorOwner(store, key, ownerRef.current) : false, + [key, store] + ); + + return useSyncExternalStore(subscribe, getSnapshot, () => false); +} diff --git a/src/modules/ProjectManager/WorkItems/hooks/useWorkItems.ts b/src/modules/ProjectManager/WorkItems/hooks/useWorkItems.ts index 90b7780703..a8d8d1d134 100644 --- a/src/modules/ProjectManager/WorkItems/hooks/useWorkItems.ts +++ b/src/modules/ProjectManager/WorkItems/hooks/useWorkItems.ts @@ -84,6 +84,7 @@ export function useWorkItems(options: UseWorkItemsOptions = {}) { // Initialize handlers const handlers = useWorkItemsHandlers({ + projectSlug, selectedWorkItemId: state.selectedWorkItemId, showProperties: state.showProperties, propertiesWasOpenRef: state.propertiesWasOpenRef, diff --git a/src/modules/ProjectManager/WorkItems/hooks/useWorkItemsData.ts b/src/modules/ProjectManager/WorkItems/hooks/useWorkItemsData.ts index ae4a42ee5b..eaa9b0e081 100644 --- a/src/modules/ProjectManager/WorkItems/hooks/useWorkItemsData.ts +++ b/src/modules/ProjectManager/WorkItems/hooks/useWorkItemsData.ts @@ -197,11 +197,17 @@ export function useWorkItemsData({ // Listen for orgii-data-changed events useProjectDataChanged( - useCallback(() => { - if (isActive) { - fetchViewData(); - } - }, [isActive, fetchViewData]) + useCallback( + (change) => { + if ( + isActive && + (!change?.projectSlug || change.projectSlug === projectSlug) + ) { + fetchViewData(); + } + }, + [isActive, fetchViewData, projectSlug] + ) ); // ============================================ diff --git a/src/modules/ProjectManager/WorkItems/hooks/useWorkItemsHandlers.ts b/src/modules/ProjectManager/WorkItems/hooks/useWorkItemsHandlers.ts index bc992e2a29..c8900b8125 100644 --- a/src/modules/ProjectManager/WorkItems/hooks/useWorkItemsHandlers.ts +++ b/src/modules/ProjectManager/WorkItems/hooks/useWorkItemsHandlers.ts @@ -43,6 +43,7 @@ function isWorkItemStatus(status: TaskStatus): status is WorkItemStatus { } interface UseWorkItemsHandlersParams { + projectSlug?: string | null; selectedWorkItemId: string | null; showProperties: boolean; propertiesWasOpenRef: MutableRefObject; @@ -83,6 +84,7 @@ interface UseWorkItemsHandlersParams { } export function useWorkItemsHandlers({ + projectSlug, selectedWorkItemId, showProperties, propertiesWasOpenRef, @@ -187,12 +189,16 @@ export function useWorkItemsHandlers({ status: fileStatus, }); - await emit("orgii-data-changed"); + await emit("orgii-data-changed", { + project_slug: projectSlug ?? undefined, + work_item_id: workItemId ?? undefined, + source: "work-items-create", + }); if (workItemId) { selectAndCollapseProperties(workItemId); } }, - [createWorkItemApi, selectAndCollapseProperties, t] + [createWorkItemApi, projectSlug, selectAndCollapseProperties, t] ); const handleAddTask = useCallback( @@ -215,7 +221,11 @@ export function useWorkItemsHandlers({ async (workItemId: string) => { const shortId = getShortId(workItemId) ?? undefined; await deleteWorkItemApi(workItemId, shortId); - await emit("orgii-data-changed"); + await emit("orgii-data-changed", { + project_slug: projectSlug ?? undefined, + work_item_id: shortId, + source: "work-items-delete", + }); if (selectedWorkItemId === workItemId) { setSelectedWorkItemId(null); @@ -226,6 +236,7 @@ export function useWorkItemsHandlers({ [ deleteWorkItemApi, getShortId, + projectSlug, selectedWorkItemId, setSelectedWorkItemId, refreshWorkItems, diff --git a/src/modules/shared/githubIssueDetailCoordinator.test.ts b/src/modules/shared/githubIssueDetailCoordinator.test.ts new file mode 100644 index 0000000000..6f795366c5 --- /dev/null +++ b/src/modules/shared/githubIssueDetailCoordinator.test.ts @@ -0,0 +1,103 @@ +import { createStore } from "jotai"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { GitHubIssue } from "@src/api/tauri/github"; + +import { + getGitHubIssueDetailCoordinatorStats, + loadGitHubDuplicateCandidates, + loadGitHubIssueDetailBundle, + resetGitHubIssueDetailCoordinator, +} from "./githubIssueDetailCoordinator"; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((nextResolve) => { + resolve = nextResolve; + }); + return { promise, resolve }; +} + +function bundle(number: number) { + return { + issue: { number } as GitHubIssue, + timeline: [], + error: null, + }; +} + +describe("githubIssueDetailCoordinator", () => { + beforeEach(() => resetGitHubIssueDetailCoordinator()); + + it("single-flights equal requests within one store", async () => { + const store = createStore(); + const pending = deferred>(); + const loader = vi.fn(() => pending.promise); + + const first = loadGitHubIssueDetailBundle(store, "auth|org/repo#1", loader); + const second = loadGitHubIssueDetailBundle( + store, + "auth|org/repo#1", + loader + ); + + expect(first).toBe(second); + expect(loader).toHaveBeenCalledOnce(); + pending.resolve(bundle(1)); + await expect(first).resolves.toEqual(bundle(1)); + }); + + it("isolates stores and bounds retained issue snapshots", async () => { + const firstStore = createStore(); + const secondStore = createStore(); + const loader = vi.fn(async () => bundle(1)); + + await Promise.all([ + loadGitHubIssueDetailBundle(firstStore, "auth|org/repo#1", loader), + loadGitHubIssueDetailBundle(secondStore, "auth|org/repo#1", loader), + ]); + expect(loader).toHaveBeenCalledTimes(2); + + for (let number = 2; number <= 40; number += 1) { + await loadGitHubIssueDetailBundle( + firstStore, + `auth|org/repo#${number}`, + async () => bundle(number) + ); + } + + expect( + getGitHubIssueDetailCoordinatorStats(firstStore).issueDetails + ).toMatchObject({ entries: 24, maxEntries: 24, inFlight: 0 }); + expect( + getGitHubIssueDetailCoordinatorStats(secondStore).issueDetails.entries + ).toBe(1); + }); + + it("does not retain failed detail responses", async () => { + const store = createStore(); + const loader = vi.fn(async () => ({ + issue: null, + timeline: [], + error: "offline", + })); + + await loadGitHubIssueDetailBundle(store, "auth|org/repo#1", loader); + await loadGitHubIssueDetailBundle(store, "auth|org/repo#1", loader); + + expect(loader).toHaveBeenCalledTimes(2); + expect( + getGitHubIssueDetailCoordinatorStats(store).issueDetails.entries + ).toBe(0); + }); + + it("keeps duplicate-candidate results scoped to the current issue", async () => { + const store = createStore(); + const loader = vi.fn(async () => []); + + await loadGitHubDuplicateCandidates(store, "auth", "org/repo", 1, loader); + await loadGitHubDuplicateCandidates(store, "auth", "org/repo", 2, loader); + + expect(loader).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/modules/shared/githubIssueDetailCoordinator.ts b/src/modules/shared/githubIssueDetailCoordinator.ts new file mode 100644 index 0000000000..0998fec424 --- /dev/null +++ b/src/modules/shared/githubIssueDetailCoordinator.ts @@ -0,0 +1,320 @@ +import type { Store } from "jotai/vanilla/store"; + +import { + type GitHubIssue, + type GitHubIssueTimelineItem, + type GitHubIssueUser, + type GitHubRepoPermissions, + getGitCredentialForRemote, +} from "@src/api/tauri/github"; +import { ScopedResourceCache } from "@src/services/git/scopedResourceCache"; + +const GITHUB_ENDPOINT = "github.com"; +const ISSUE_DETAIL_TTL_MS = 30_000; +const REPO_METADATA_TTL_MS = 2 * 60_000; +const VIEWER_TTL_MS = 5 * 60_000; +const REMOTE_TTL_MS = 5 * 60_000; + +const MAX_ISSUE_DETAILS = 24; +const MAX_REPO_RESOURCES = 16; +const MAX_AUTH_IDENTITIES = 4; +const MAX_ISSUE_DETAIL_BYTES = 4 * 1024 * 1024; +const MAX_ISSUE_ENTRY_BYTES = 512 * 1024; + +export interface GitHubIssueDetailBundle { + issue: GitHubIssue | null; + timeline: GitHubIssueTimelineItem[]; + error: string | null; +} + +interface CoordinatorRuntime { + authScopes: ScopedResourceCache; + issueDetails: ScopedResourceCache; + timelines: ScopedResourceCache; + viewers: ScopedResourceCache; + permissions: ScopedResourceCache; + duplicateCandidates: ScopedResourceCache; + assignableUsers: ScopedResourceCache; + remoteUrls: ScopedResourceCache; +} + +export interface GitHubIssueDetailCoordinatorStats { + authScopes: ReturnType["getStats"]>; + issueDetails: ReturnType< + ScopedResourceCache["getStats"] + >; + timelines: ReturnType< + ScopedResourceCache["getStats"] + >; + viewers: ReturnType["getStats"]>; + permissions: ReturnType< + ScopedResourceCache["getStats"] + >; + duplicateCandidates: ReturnType< + ScopedResourceCache["getStats"] + >; + assignableUsers: ReturnType< + ScopedResourceCache["getStats"] + >; + remoteUrls: ReturnType["getStats"]>; +} + +let runtimeByStore = new WeakMap(); + +function estimateSerializedBytes(value: unknown): number { + try { + return JSON.stringify(value).length * 2; + } catch { + return Number.POSITIVE_INFINITY; + } +} + +function createRuntime(): CoordinatorRuntime { + return { + authScopes: new ScopedResourceCache({ + maxEntries: 1, + maxInFlight: 1, + // Credential selection can change without a React/store reset. Keep + // only the in-flight coalescing benefit; every later request rechecks + // the non-secret identity before choosing any account-scoped cache key. + maxAgeMs: 0, + }), + issueDetails: new ScopedResourceCache({ + maxEntries: MAX_ISSUE_DETAILS, + maxInFlight: MAX_ISSUE_DETAILS, + maxAgeMs: ISSUE_DETAIL_TTL_MS, + maxBytes: MAX_ISSUE_DETAIL_BYTES, + maxEntryBytes: MAX_ISSUE_ENTRY_BYTES, + estimateSize: estimateSerializedBytes, + }), + timelines: new ScopedResourceCache({ + maxEntries: MAX_ISSUE_DETAILS, + maxInFlight: MAX_ISSUE_DETAILS, + maxAgeMs: ISSUE_DETAIL_TTL_MS, + maxBytes: MAX_ISSUE_DETAIL_BYTES, + maxEntryBytes: MAX_ISSUE_ENTRY_BYTES, + estimateSize: estimateSerializedBytes, + }), + viewers: new ScopedResourceCache({ + maxEntries: MAX_AUTH_IDENTITIES, + maxInFlight: MAX_AUTH_IDENTITIES, + maxAgeMs: VIEWER_TTL_MS, + }), + permissions: new ScopedResourceCache({ + maxEntries: MAX_REPO_RESOURCES, + maxInFlight: MAX_REPO_RESOURCES, + maxAgeMs: REPO_METADATA_TTL_MS, + }), + duplicateCandidates: new ScopedResourceCache({ + maxEntries: MAX_REPO_RESOURCES, + maxInFlight: MAX_REPO_RESOURCES, + maxAgeMs: ISSUE_DETAIL_TTL_MS, + maxBytes: MAX_ISSUE_DETAIL_BYTES, + maxEntryBytes: MAX_ISSUE_ENTRY_BYTES, + estimateSize: estimateSerializedBytes, + }), + assignableUsers: new ScopedResourceCache({ + maxEntries: MAX_REPO_RESOURCES, + maxInFlight: MAX_REPO_RESOURCES, + maxAgeMs: REPO_METADATA_TTL_MS, + maxBytes: MAX_ISSUE_DETAIL_BYTES, + maxEntryBytes: MAX_ISSUE_ENTRY_BYTES, + estimateSize: estimateSerializedBytes, + }), + remoteUrls: new ScopedResourceCache({ + maxEntries: MAX_REPO_RESOURCES, + maxInFlight: MAX_REPO_RESOURCES, + maxAgeMs: REMOTE_TTL_MS, + }), + }; +} + +function runtimeFor(store: Store): CoordinatorRuntime { + let runtime = runtimeByStore.get(store); + if (!runtime) { + runtime = createRuntime(); + runtimeByStore.set(store, runtime); + } + return runtime; +} + +/** + * Resolve a non-secret identity for the active GitHub credential. The token is + * deliberately discarded immediately; only endpoint + connection metadata is + * retained in request/cache keys. + */ +export async function resolveGitHubDetailAuthScope(): Promise { + const credential = await getGitCredentialForRemote( + `https://${GITHUB_ENDPOINT}` + ); + return credential + ? `${GITHUB_ENDPOINT}:${credential.connection_id}:${credential.source}:${credential.username}` + : `${GITHUB_ENDPOINT}:anonymous`; +} + +export function loadGitHubDetailAuthScope(store: Store): Promise { + return runtimeFor(store).authScopes.load( + GITHUB_ENDPOINT, + resolveGitHubDetailAuthScope + ); +} + +export function githubIssueResourceKey( + authScope: string, + repoFullName: string, + issueNumber: number +): string { + return `${authScope}|${repoFullName.toLowerCase()}#${issueNumber}`; +} + +function repoResourceKey(authScope: string, repoFullName: string): string { + return `${authScope}|${repoFullName.toLowerCase()}`; +} + +export function loadGitHubIssueDetailBundle( + store: Store, + resourceKey: string, + loader: () => Promise, + options?: { force?: boolean } +): Promise { + return runtimeFor(store).issueDetails.load(resourceKey, loader, { + ...options, + shouldCache: (bundle) => Boolean(bundle.issue && !bundle.error), + }); +} + +export function primeGitHubIssueDetailBundle( + store: Store, + resourceKey: string, + bundle: GitHubIssueDetailBundle +): void { + runtimeFor(store).issueDetails.set(resourceKey, bundle); +} + +export function invalidateGitHubIssueDetailBundle( + store: Store, + resourceKey: string +): void { + runtimeFor(store).issueDetails.delete(resourceKey); +} + +export function loadGitHubIssueTimeline( + store: Store, + resourceKey: string, + loader: () => Promise, + options?: { force?: boolean } +): Promise { + return runtimeFor(store).timelines.load(resourceKey, loader, options); +} + +export function primeGitHubIssueTimeline( + store: Store, + resourceKey: string, + timeline: GitHubIssueTimelineItem[] +): void { + runtimeFor(store).timelines.set(resourceKey, timeline); +} + +export function invalidateGitHubIssueTimeline( + store: Store, + resourceKey: string +): void { + runtimeFor(store).timelines.delete(resourceKey); +} + +export function loadGitHubViewer( + store: Store, + authScope: string, + loader: () => Promise +): Promise { + return runtimeFor(store).viewers.load(authScope, loader); +} + +export function primeGitHubViewer( + store: Store, + authScope: string, + viewerLogin: string +): void { + runtimeFor(store).viewers.set(authScope, viewerLogin); +} + +export function loadGitHubRepoPermissions( + store: Store, + authScope: string, + repoFullName: string, + loader: () => Promise +): Promise { + return runtimeFor(store).permissions.load( + repoResourceKey(authScope, repoFullName), + loader + ); +} + +export function primeGitHubRepoPermissions( + store: Store, + authScope: string, + repoFullName: string, + permissions: GitHubRepoPermissions +): void { + runtimeFor(store).permissions.set( + repoResourceKey(authScope, repoFullName), + permissions + ); +} + +export function loadGitHubDuplicateCandidates( + store: Store, + authScope: string, + repoFullName: string, + issueNumber: number, + loader: () => Promise +): Promise { + return runtimeFor(store).duplicateCandidates.load( + `${repoResourceKey(authScope, repoFullName)}#duplicates-for:${issueNumber}`, + loader + ); +} + +export function loadGitHubAssignableUsers( + store: Store, + authScope: string, + repoFullName: string, + loader: () => Promise +): Promise { + return runtimeFor(store).assignableUsers.load( + repoResourceKey(authScope, repoFullName), + loader + ); +} + +export function loadGitHubRemoteUrl( + store: Store, + repoPath: string, + loader: () => Promise +): Promise { + return runtimeFor(store).remoteUrls.load(repoPath, loader); +} + +export function getGitHubIssueDetailCoordinatorStats( + store: Store +): GitHubIssueDetailCoordinatorStats { + const runtime = runtimeFor(store); + return { + authScopes: runtime.authScopes.getStats(), + issueDetails: runtime.issueDetails.getStats(), + timelines: runtime.timelines.getStats(), + viewers: runtime.viewers.getStats(), + permissions: runtime.permissions.getStats(), + duplicateCandidates: runtime.duplicateCandidates.getStats(), + assignableUsers: runtime.assignableUsers.getStats(), + remoteUrls: runtime.remoteUrls.getStats(), + }; +} + +export function resetGitHubIssueDetailCoordinator(store?: Store): void { + if (store) { + runtimeByStore.delete(store); + return; + } + runtimeByStore = new WeakMap(); +} diff --git a/src/modules/shared/hooks/useGitHubIssueDetailState.test.ts b/src/modules/shared/hooks/useGitHubIssueDetailState.test.ts index 407f87542e..81b726ff98 100644 --- a/src/modules/shared/hooks/useGitHubIssueDetailState.test.ts +++ b/src/modules/shared/hooks/useGitHubIssueDetailState.test.ts @@ -15,6 +15,10 @@ import { } from "vitest"; import type { GitHubIssue } from "@src/api/tauri/github"; +import { + githubIssueResourceKey, + resetGitHubIssueDetailCoordinator, +} from "@src/modules/shared/githubIssueDetailCoordinator"; import { workstationSelectedIssueAtomFamily } from "@src/store/workstation/codeEditor/workstationIssueAtom"; import { @@ -26,6 +30,8 @@ const mocks = vi.hoisted(() => ({ createIssueCommentLocal: vi.fn(), getGitHubRepoPermissionsLocal: vi.fn(), getGitHubViewerLogin: vi.fn(), + fetchIssue: vi.fn(), + fetchIssueTimeline: vi.fn(), listIssueTimelineLocal: vi.fn(), listIssuesLocal: vi.fn(), listRepoAssigneesLocal: vi.fn(), @@ -35,8 +41,8 @@ const mocks = vi.hoisted(() => ({ vi.mock("@src/api/tauri/github", () => mocks); vi.mock("@src/services/git/operations/githubIssues", () => ({ - fetchIssue: vi.fn(), - fetchIssueTimeline: vi.fn(), + fetchIssue: mocks.fetchIssue, + fetchIssueTimeline: mocks.fetchIssueTimeline, issueCommentToTimelineItem: vi.fn(), })); @@ -66,6 +72,7 @@ function Probe() { issueNumber: issue.number, repoPath: "/repos/ORG2", stateScopeKey: "issue-detail-test", + authScope: "test-auth", }); return createElement( @@ -113,6 +120,20 @@ function Probe() { ); } +function ColdProbe({ stateScopeKey }: { stateScopeKey: string }) { + const { selectedState } = useGitHubIssueDetailState({ + issueNumber: issue.number, + repoPath: "/repos/ORG2", + remoteUrl: "git@github.com:org2AI/ORG2.git", + stateScopeKey, + authScope: "test-auth", + }); + return createElement("div", { + "data-testid": stateScopeKey, + "data-loading": String(selectedState.loading), + }); +} + describe("useGitHubIssueDetailState", () => { let container: HTMLDivElement; let root: Root; @@ -127,7 +148,10 @@ describe("useGitHubIssueDetailState", () => { beforeEach(() => { vi.clearAllMocks(); + resetGitHubIssueDetailCoordinator(store); mocks.getGitHubViewerLogin.mockResolvedValue("viewer"); + mocks.fetchIssue.mockResolvedValue({ data: issue }); + mocks.fetchIssueTimeline.mockResolvedValue({ data: [] }); mocks.getGitHubRepoPermissionsLocal.mockResolvedValue({ role_name: "write", can_manage_issues: true, @@ -135,6 +159,11 @@ describe("useGitHubIssueDetailState", () => { }); mocks.listRepoAssigneesLocal.mockResolvedValue([]); store.set(workstationSelectedIssueAtomFamily("issue-detail-test"), { + resourceKey: githubIssueResourceKey( + "test-auth", + "org2AI/ORG2", + issue.number + ), issue, timeline: [], loading: false, @@ -157,6 +186,9 @@ describe("useGitHubIssueDetailState", () => { }); it("resolves only repository-shaped remotes and GitHub issue URLs", () => { + expect(resolveGitHubIssueRepoFullName("org2AI/ORG2", undefined)).toBe( + "org2AI/ORG2" + ); expect( resolveGitHubIssueRepoFullName( "git@github.com:org2AI/ORG2.git", @@ -197,6 +229,31 @@ describe("useGitHubIssueDetailState", () => { expect(mocks.listIssuesLocal).not.toHaveBeenCalled(); }); + it("single-flights cold detail, timeline, viewer, and permission reads across hosts", async () => { + await act(async () => { + root.render( + createElement( + Provider, + { store }, + createElement(ColdProbe, { stateScopeKey: "cold-host-a" }), + createElement(ColdProbe, { stateScopeKey: "cold-host-b" }) + ) + ); + }); + + await vi.waitFor(() => { + expect( + container + .querySelector("[data-testid='cold-host-a']") + ?.getAttribute("data-loading") + ).toBe("false"); + }); + expect(mocks.fetchIssue).toHaveBeenCalledOnce(); + expect(mocks.fetchIssueTimeline).toHaveBeenCalledOnce(); + expect(mocks.getGitHubViewerLogin).toHaveBeenCalledOnce(); + expect(mocks.getGitHubRepoPermissionsLocal).toHaveBeenCalledOnce(); + }); + it("single-flights concurrent duplicate-candidate requests", async () => { let resolveCandidates: | ((value: { @@ -353,6 +410,11 @@ describe("useGitHubIssueDetailState", () => { }; act(() => { store.set(workstationSelectedIssueAtomFamily("issue-detail-test"), { + resourceKey: githubIssueResourceKey( + "test-auth", + "acme/other", + otherIssue.number + ), issue: otherIssue, timeline: [], loading: false, diff --git a/src/modules/shared/hooks/useGitHubIssueDetailState.ts b/src/modules/shared/hooks/useGitHubIssueDetailState.ts index 4fde3df779..174e47948f 100644 --- a/src/modules/shared/hooks/useGitHubIssueDetailState.ts +++ b/src/modules/shared/hooks/useGitHubIssueDetailState.ts @@ -1,10 +1,11 @@ -import { useAtomValue, useSetAtom } from "jotai"; +import { useAtomValue, useSetAtom, useStore } from "jotai"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createIssueCommentLocal, getGitHubRepoPermissionsLocal, getGitHubViewerLogin, + getIssueLocal, listIssueTimelineLocal, listIssuesLocal, listRepoAssigneesLocal, @@ -25,6 +26,22 @@ import { issueHasAssigneeLogins, resolveGitHubAssigneeUsers, } from "@src/modules/shared/githubIssueAssignees"; +import { + githubIssueResourceKey, + invalidateGitHubIssueDetailBundle, + invalidateGitHubIssueTimeline, + loadGitHubAssignableUsers, + loadGitHubDetailAuthScope, + loadGitHubDuplicateCandidates, + loadGitHubIssueDetailBundle, + loadGitHubIssueTimeline, + loadGitHubRepoPermissions, + loadGitHubViewer, + primeGitHubIssueDetailBundle, + primeGitHubIssueTimeline, + primeGitHubRepoPermissions, + primeGitHubViewer, +} from "@src/modules/shared/githubIssueDetailCoordinator"; import { parseGithubRepoFullName } from "@src/services/git/operations/createPullRequest"; import { fetchIssue, @@ -32,6 +49,7 @@ import { issueCommentToTimelineItem, } from "@src/services/git/operations/githubIssues"; import { + retainWorkstationIssueDetailScope, workstationIssueCallbackAtomFamily, workstationSelectedIssueAtomFamily, } from "@src/store/workstation/codeEditor/workstationIssueAtom"; @@ -44,6 +62,10 @@ export interface GitHubIssueDetailStateOptions { repoId?: string; remoteUrl?: string; stateScopeKey?: string; + /** Identity-scoped list data can provide these to skip duplicate requests. */ + authScope?: string; + viewerLogin?: string | null; + repoPermissions?: GitHubRepoPermissions | null; } interface GitHubIssueInteractionResolution { @@ -90,13 +112,14 @@ function resolveViewer( } function isRepoFullName(value: string | null): value is string { - return Boolean(value && /^[^/\s]+\/[^/\s]+$/.test(value)); + return Boolean(value && /^[^/:@\s]+\/[^/\s]+$/.test(value)); } export function resolveGitHubIssueRepoFullName( remoteUrl: string | undefined, issueUrl: string | undefined ): string | null { + if (isRepoFullName(remoteUrl ?? null)) return remoteUrl ?? null; const remoteRepo = remoteUrl ? parseGithubRepoFullName(remoteUrl) : null; if (isRepoFullName(remoteRepo)) return remoteRepo; @@ -132,7 +155,11 @@ export function useGitHubIssueDetailState({ repoId, remoteUrl, stateScopeKey: requestedStateScopeKey, + authScope: providedAuthScope, + viewerLogin: providedViewerLogin, + repoPermissions: providedRepoPermissions, }: GitHubIssueDetailStateOptions) { + const store = useStore(); const repoScopeKey = workstationRepoScopeKey(repoId, repoPath); const stateScopeKey = requestedStateScopeKey ?? repoScopeKey; const selectedState = useAtomValue( @@ -147,19 +174,17 @@ export function useGitHubIssueDetailState({ ); const [resolution, setResolution] = useState(null); + const authResolutionKey = `${repoPath}|${remoteUrl ?? ""}`; + const [resolvedAuth, setResolvedAuth] = useState<{ + key: string; + scope: string | null; + } | null>(null); + const authScope = + providedAuthScope ?? + (resolvedAuth?.key === authResolutionKey ? resolvedAuth.scope : null); const selectedIssueRef = useRef(selectedState.issue); const selectedTimelineRef = useRef(selectedState.timeline); const requestGenerationRef = useRef(0); - const duplicateRequestRef = useRef<{ - key: string; - generation: number; - promise: Promise; - } | null>(null); - const assigneeRequestRef = useRef<{ - key: string; - generation: number; - promise: Promise; - } | null>(null); const assigneeMutationRef = useRef<{ key: string; generation: number; @@ -171,60 +196,176 @@ export function useGitHubIssueDetailState({ }, [selectedState.issue, selectedState.timeline]); useEffect(() => { - if ( - issueNumber <= 0 || - selectedState.issue?.number === issueNumber || - !remoteUrl - ) { + // Repo-scoped Source Control state owns the user's current selection and + // must survive switching editor panes. Standalone detail scopes are + // disposable and otherwise grow the primitive-key atom family forever. + const preserveSelection = stateScopeKey === repoScopeKey; + const release = retainWorkstationIssueDetailScope(stateScopeKey, { + evictOnFinalRelease: !preserveSelection, + }); + return () => { + if (release() && preserveSelection) { + // The issue remains selected for the next Source Control visit, but + // the potentially large timeline has a bounded mounted lifetime. + setSelectedState((current) => ({ + ...current, + resourceKey: null, + timeline: [], + timelineLoading: false, + })); + } + }; + }, [repoScopeKey, setSelectedState, stateScopeKey]); + + useEffect(() => { + if (providedAuthScope || (!repoPath && !remoteUrl)) return; + let cancelled = false; + void loadGitHubDetailAuthScope(store) + .then((scope) => { + if (!cancelled) setResolvedAuth({ key: authResolutionKey, scope }); + }) + .catch(() => { + if (!cancelled) + setResolvedAuth({ key: authResolutionKey, scope: null }); + }); + return () => { + cancelled = true; + }; + }, [authResolutionKey, providedAuthScope, remoteUrl, repoPath, store]); + + const repoFullName = resolveGitHubIssueRepoFullName( + remoteUrl, + selectedState.issue?.html_url + ); + const requestKey = + authScope && repoFullName && issueNumber > 0 + ? githubIssueResourceKey(authScope, repoFullName, issueNumber) + : null; + const selectedStateMatches = + Boolean(requestKey) && + selectedState.resourceKey === requestKey && + selectedState.issue?.number === issueNumber; + const currentResolution = resolution?.key === requestKey ? resolution : null; + + useEffect(() => { + if (!requestKey || issueNumber <= 0 || selectedStateMatches) { return; } let cancelled = false; setSelectedState((prev) => ({ ...prev, + resourceKey: requestKey, + issue: null, + timeline: [], loading: true, timelineLoading: true, error: null, })); - void Promise.all([ - fetchIssue(remoteUrl, issueNumber), - fetchIssueTimeline({ remoteUrl, issueNumber }), - ]).then(([issueResult, timelineResult]) => { - if (cancelled) return; - setSelectedState((prev) => ({ - ...prev, + void loadGitHubIssueDetailBundle(store, requestKey, async () => { + const issueResultPromise = remoteUrl + ? fetchIssue(remoteUrl, issueNumber) + : getIssueLocal(repoFullName!, issueNumber).then((issue) => ({ + data: issue, + error: null, + })); + let timeline: GitHubIssueTimelineItem[] = []; + let timelineError: string | null = null; + try { + timeline = await loadGitHubIssueTimeline( + store, + requestKey, + async () => { + if (!remoteUrl) { + return listIssueTimelineLocal(repoFullName!, issueNumber); + } + const timelineResult = await fetchIssueTimeline({ + remoteUrl, + issueNumber, + }); + if (timelineResult.error) throw new Error(timelineResult.error); + return timelineResult.data ?? []; + } + ); + } catch (error) { + timelineError = error instanceof Error ? error.message : String(error); + } + const issueResult = await issueResultPromise; + return { issue: issueResult.data ?? null, - timeline: timelineResult.data ?? [], - loading: false, - timelineLoading: false, - error: issueResult.error ?? timelineResult.error ?? null, - })); - }); + timeline, + error: issueResult.error ?? timelineError, + }; + }) + .then((bundle) => { + if (cancelled) return; + setSelectedState((prev) => + prev.resourceKey === requestKey + ? { + ...prev, + issue: bundle.issue, + timeline: bundle.timeline, + loading: false, + timelineLoading: false, + error: bundle.error, + } + : prev + ); + }) + .catch((error: unknown) => { + if (cancelled) return; + setSelectedState((prev) => + prev.resourceKey === requestKey + ? { + ...prev, + loading: false, + timelineLoading: false, + error: error instanceof Error ? error.message : String(error), + } + : prev + ); + }); return () => { cancelled = true; }; - }, [issueNumber, remoteUrl, selectedState.issue?.number, setSelectedState]); - - const repoFullName = resolveGitHubIssueRepoFullName( + }, [ + issueNumber, + repoFullName, remoteUrl, - selectedState.issue?.html_url - ); - const requestKey = - repoFullName && issueNumber > 0 ? `${repoFullName}#${issueNumber}` : null; - const currentResolution = resolution?.key === requestKey ? resolution : null; + requestKey, + selectedStateMatches, + setSelectedState, + store, + ]); useEffect(() => { const generation = ++requestGenerationRef.current; - if (!requestKey || !repoFullName) { - duplicateRequestRef.current = null; - return; - } + if (!requestKey || !repoFullName || !authScope) return; let cancelled = false; - const currentIssue = selectedIssueRef.current; - const currentTimeline = selectedTimelineRef.current; - + const currentIssue = + selectedState.resourceKey === requestKey + ? selectedIssueRef.current + : null; + const currentTimeline = + selectedState.resourceKey === requestKey + ? selectedTimelineRef.current + : []; + + if (providedViewerLogin) { + primeGitHubViewer(store, authScope, providedViewerLogin); + } + if (providedRepoPermissions) { + primeGitHubRepoPermissions( + store, + authScope, + repoFullName, + providedRepoPermissions + ); + } void Promise.allSettled([ - getGitHubViewerLogin(), - getGitHubRepoPermissionsLocal(repoFullName), + loadGitHubViewer(store, authScope, getGitHubViewerLogin), + loadGitHubRepoPermissions(store, authScope, repoFullName, () => + getGitHubRepoPermissionsLocal(repoFullName) + ), ]).then(([viewerResult, permissionsResult]) => { if (cancelled || requestGenerationRef.current !== generation) return; setResolution({ @@ -258,29 +399,25 @@ export function useGitHubIssueDetailState({ if (requestGenerationRef.current === generation) { requestGenerationRef.current += 1; } - if (duplicateRequestRef.current?.key === requestKey) { - duplicateRequestRef.current = null; - } - if (assigneeRequestRef.current?.key === requestKey) { - assigneeRequestRef.current = null; - } }; - }, [repoFullName, requestKey]); + }, [ + authScope, + providedRepoPermissions, + providedViewerLogin, + repoFullName, + requestKey, + selectedState.resourceKey, + store, + ]); const loadDuplicateCandidates = useCallback((): Promise => { - if (!requestKey || !repoFullName || !currentResolution) { + if (!requestKey || !repoFullName || !authScope || !currentResolution) { return Promise.reject(new Error("github_duplicate_issues_unavailable")); } if (currentResolution.duplicateCandidatesLoaded) { return Promise.resolve(); } const generation = requestGenerationRef.current; - if ( - duplicateRequestRef.current?.key === requestKey && - duplicateRequestRef.current.generation === generation - ) { - return duplicateRequestRef.current.promise; - } setResolution((current) => current?.key === requestKey @@ -292,19 +429,27 @@ export function useGitHubIssueDetailState({ : current ); - const promise = listIssuesLocal(repoFullName, { - state: "all", - page: 1, - perPage: 100, - includeLinkedPullRequests: false, - }) - .then(({ issues }) => { - const candidates = issues.filter( + return loadGitHubDuplicateCandidates( + store, + authScope, + repoFullName, + issueNumber, + async () => { + const { issues } = await listIssuesLocal(repoFullName, { + state: "all", + page: 1, + perPage: 100, + includeLinkedPullRequests: false, + }); + return issues.filter( (candidate) => candidate.number !== issueNumber && typeof candidate.id === "number" && candidate.id > 0 ); + } + ) + .then((candidates) => { setResolution((current) => current?.key === requestKey && requestGenerationRef.current === generation @@ -330,21 +475,21 @@ export function useGitHubIssueDetailState({ : current ); throw error; - }) - .finally(() => { - if (duplicateRequestRef.current?.promise === promise) { - duplicateRequestRef.current = null; - } }); - - duplicateRequestRef.current = { key: requestKey, generation, promise }; - return promise; - }, [currentResolution, issueNumber, repoFullName, requestKey]); + }, [ + authScope, + currentResolution, + issueNumber, + repoFullName, + requestKey, + store, + ]); const loadAssignableUsers = useCallback((): Promise => { if ( !requestKey || !repoFullName || + !authScope || !currentResolution || currentResolution.permissions?.can_manage_issues !== true ) { @@ -354,12 +499,6 @@ export function useGitHubIssueDetailState({ return Promise.resolve(); } const generation = requestGenerationRef.current; - if ( - assigneeRequestRef.current?.key === requestKey && - assigneeRequestRef.current.generation === generation - ) { - return assigneeRequestRef.current.promise; - } setResolution((current) => current?.key === requestKey @@ -371,7 +510,9 @@ export function useGitHubIssueDetailState({ : current ); - const promise = listRepoAssigneesLocal(repoFullName) + return loadGitHubAssignableUsers(store, authScope, repoFullName, () => + listRepoAssigneesLocal(repoFullName) + ) .then((users) => { setResolution((current) => current?.key === requestKey && @@ -398,16 +539,8 @@ export function useGitHubIssueDetailState({ } : current ); - }) - .finally(() => { - if (assigneeRequestRef.current?.promise === promise) { - assigneeRequestRef.current = null; - } }); - - assigneeRequestRef.current = { key: requestKey, generation, promise }; - return promise; - }, [currentResolution, repoFullName, requestKey]); + }, [authScope, currentResolution, repoFullName, requestKey, store]); const changeAssignees = useCallback( async (assigneeLogins: string[]): Promise => { @@ -462,6 +595,7 @@ export function useGitHubIssueDetailState({ if (!issueHasAssigneeLogins(updatedIssue, assigneeLogins)) { throw new Error("GitHub did not apply the assignee update."); } + invalidateGitHubIssueDetailBundle(store, requestKey); setSelectedState((current) => requestGenerationRef.current === generation && current.issue?.id === issue.id @@ -516,6 +650,7 @@ export function useGitHubIssueDetailState({ requestKey, selectedState.issue, setSelectedState, + store, ] ); @@ -543,6 +678,8 @@ export function useGitHubIssueDetailState({ issue.number, body ); + invalidateGitHubIssueDetailBundle(store, requestKey); + invalidateGitHubIssueTimeline(store, requestKey); setSelectedState((current) => current.issue?.number === issue.number ? { @@ -578,6 +715,7 @@ export function useGitHubIssueDetailState({ requestKey, selectedState.issue, setSelectedState, + store, ] ); @@ -609,6 +747,7 @@ export function useGitHubIssueDetailState({ issue.number, { body } ); + invalidateGitHubIssueDetailBundle(store, requestKey); setSelectedState((current) => current.issue?.number === issue.number ? { ...current, issue: updatedIssue } @@ -636,6 +775,7 @@ export function useGitHubIssueDetailState({ requestKey, selectedState.issue, setSelectedState, + store, ] ); @@ -684,10 +824,18 @@ export function useGitHubIssueDetailState({ : {}), } ); - const timeline = await listIssueTimelineLocal( - repoFullName, - issue.number + const timeline = await loadGitHubIssueTimeline( + store, + requestKey, + () => listIssueTimelineLocal(repoFullName, issue.number), + { force: true } ).catch(() => selectedState.timeline); + primeGitHubIssueTimeline(store, requestKey, timeline); + primeGitHubIssueDetailBundle(store, requestKey, { + issue: updatedIssue, + timeline, + error: null, + }); setSelectedState((current) => current.issue?.number === issue.number ? { ...current, issue: updatedIssue, timeline } @@ -716,15 +864,23 @@ export function useGitHubIssueDetailState({ selectedState.issue, selectedState.timeline, setSelectedState, + store, ] ); const interaction = useMemo(() => { const issue = selectedState.issue; const canManageStatus = canEditIssue(currentResolution, issue); + const viewer = currentResolution?.viewer + ? resolveViewer( + currentResolution.viewer.login, + issue, + selectedState.timeline + ) + : null; return { - viewer: currentResolution?.viewer ?? null, + viewer, issueState: issue?.state ?? "open", duplicateCandidates: currentResolution?.duplicateCandidates ?? [], duplicateCandidatesLoaded: @@ -733,7 +889,9 @@ export function useGitHubIssueDetailState({ currentResolution?.loadingDuplicateCandidates ?? false, duplicateCandidatesError: currentResolution?.duplicateCandidatesError ?? false, - loading: Boolean(requestKey) && !currentResolution, + loading: + Boolean(requestedIssueNumber && remoteUrl) && + (!authScope || !selectedStateMatches || !currentResolution), canComment: Boolean(currentResolution?.viewer), canEditBody: canManageStatus, canManageStatus, @@ -751,8 +909,12 @@ export function useGitHubIssueDetailState({ changeStatus, currentResolution, loadDuplicateCandidates, - requestKey, + authScope, + remoteUrl, + requestedIssueNumber, + selectedStateMatches, selectedState.issue, + selectedState.timeline, updateBody, ]); @@ -796,5 +958,17 @@ export function useGitHubIssueDetailState({ selectedState.issue, ]); - return { selectedState, interaction, assigneeConfig }; + const visibleSelectedState = + requestedIssueNumber && remoteUrl && !selectedStateMatches + ? { + ...selectedState, + issue: null, + timeline: [], + loading: true, + timelineLoading: true, + error: null, + } + : selectedState; + + return { selectedState: visibleSelectedState, interaction, assigneeConfig }; } diff --git a/src/store/workstation/codeEditor/workstationIssueAtom.test.ts b/src/store/workstation/codeEditor/workstationIssueAtom.test.ts new file mode 100644 index 0000000000..ed927d8880 --- /dev/null +++ b/src/store/workstation/codeEditor/workstationIssueAtom.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; + +import { + getRetainedIssueDetailScopeCount, + retainWorkstationIssueDetailScope, + workstationSelectedIssueAtomFamily, +} from "./workstationIssueAtom"; + +describe("workstation issue detail retention", () => { + it("ref-counts mounted consumers and evicts the atom on final release", () => { + const scope = "issue-retention-test"; + const initialAtom = workstationSelectedIssueAtomFamily(scope); + const releaseFirst = retainWorkstationIssueDetailScope(scope); + const releaseSecond = retainWorkstationIssueDetailScope(scope); + + expect(getRetainedIssueDetailScopeCount()).toBeGreaterThanOrEqual(1); + releaseFirst(); + expect(workstationSelectedIssueAtomFamily(scope)).toBe(initialAtom); + + releaseSecond(); + expect(workstationSelectedIssueAtomFamily(scope)).not.toBe(initialAtom); + }); + + it("can preserve a repo selection while still releasing its retention entry", () => { + const scope = "repo-selection-retention-test"; + const initialAtom = workstationSelectedIssueAtomFamily(scope); + const release = retainWorkstationIssueDetailScope(scope, { + evictOnFinalRelease: false, + }); + + expect(release()).toBe(true); + expect(workstationSelectedIssueAtomFamily(scope)).toBe(initialAtom); + }); +}); diff --git a/src/store/workstation/codeEditor/workstationIssueAtom.ts b/src/store/workstation/codeEditor/workstationIssueAtom.ts index cdd98286ad..4b6fef5a60 100644 --- a/src/store/workstation/codeEditor/workstationIssueAtom.ts +++ b/src/store/workstation/codeEditor/workstationIssueAtom.ts @@ -25,6 +25,8 @@ export interface WorkstationIssueListState { } export interface WorkstationSelectedIssueState { + /** Auth + repository + issue identity that owns this snapshot. */ + resourceKey?: string | null; issue: GitHubIssue | null; timeline: GitHubIssueTimelineItem[]; loading: boolean; @@ -45,6 +47,7 @@ const initialListState: WorkstationIssueListState = { }; const initialSelectedState: WorkstationSelectedIssueState = { + resourceKey: null, issue: null, timeline: [], loading: false, @@ -119,3 +122,40 @@ export const workstationIssueCallbackAtomFamily = atomFamily( export const workstationIssueCallbackAtom = workstationIssueCallbackAtomFamily( DEFAULT_WORKSTATION_REPO_SCOPE ); + +const retainedIssueDetailScopes = new Map(); + +/** + * Retain an explicit issue-detail scope while a rendered consumer is mounted. + * The final release removes the atom-family entries immediately; bounded warm + * remount data belongs to the GitHub detail coordinator rather than this + * unbounded primitive-key atom family. + */ +export function retainWorkstationIssueDetailScope( + scopeKey: string, + options: { evictOnFinalRelease?: boolean } = {} +): () => boolean { + retainedIssueDetailScopes.set( + scopeKey, + (retainedIssueDetailScopes.get(scopeKey) ?? 0) + 1 + ); + let released = false; + return () => { + if (released) return false; + released = true; + const remaining = (retainedIssueDetailScopes.get(scopeKey) ?? 1) - 1; + if (remaining > 0) { + retainedIssueDetailScopes.set(scopeKey, remaining); + return false; + } + retainedIssueDetailScopes.delete(scopeKey); + if (options.evictOnFinalRelease !== false) { + workstationSelectedIssueAtomFamily.remove(scopeKey); + } + return true; + }; +} + +export function getRetainedIssueDetailScopeCount(): number { + return retainedIssueDetailScopes.size; +} diff --git a/src/store/workstation/tabs/factories/githubIssue.ts b/src/store/workstation/tabs/factories/githubIssue.ts index 11ac29076a..698cd82ab9 100644 --- a/src/store/workstation/tabs/factories/githubIssue.ts +++ b/src/store/workstation/tabs/factories/githubIssue.ts @@ -28,7 +28,10 @@ export function createGitHubIssueDetailTab( issueTitle: string, repoPath: string, remoteUrl?: string, - stateScopeKey?: string + stateScopeKey?: string, + authScope?: string, + viewerLogin?: string | null, + repoPermissions?: GitHubIssueDetailTabData["repoPermissions"] ): WorkStationTab { return githubIssueDetailTabFactory({ issueNumber, @@ -36,5 +39,8 @@ export function createGitHubIssueDetailTab( repoPath, remoteUrl, stateScopeKey, + authScope, + viewerLogin, + repoPermissions, }); } diff --git a/src/types/githubDetail.ts b/src/types/githubDetail.ts index 6196d6d8fd..ceafc301f2 100644 --- a/src/types/githubDetail.ts +++ b/src/types/githubDetail.ts @@ -4,6 +4,11 @@ export interface GitHubIssueDetailTabData { repoPath: string; remoteUrl?: string; stateScopeKey?: string; + authScope?: string; + viewerLogin?: string | null; + repoPermissions?: + | import("@src/api/tauri/github").GitHubRepoPermissions + | null; } export interface GitHubPrDetailTabData {