From 7096f9027e2f2c4191dc80e4031264519e4a3feb Mon Sep 17 00:00:00 2001 From: Defmon3 <111503233+Defmon3@users.noreply.github.com> Date: Fri, 18 Sep 2026 11:14:04 +0200 Subject: [PATCH] feat(#6315): add Issues to Repository with shared filters --- apps/web/src/components/ChatView.tsx | 102 ++++----- .../src/components/RepositoryPanel.test.tsx | 4 +- apps/web/src/components/RepositoryPanel.tsx | 75 +++++-- .../src/components/RightPanelTabs.test.tsx | 3 - apps/web/src/components/RightPanelTabs.tsx | 59 +----- apps/web/src/components/issue/IssuesPanel.tsx | 57 ++--- .../issue/issuePanelPreferences.test.ts | 107 ++++++++++ .../components/issue/issuePanelPreferences.ts | 77 +++++++ apps/web/src/rightPanelStore.test.ts | 148 ++++++++++--- apps/web/src/rightPanelStore.ts | 167 ++++++++------- .../src/routes/-chatIssuesTitlebar.test.tsx | 37 ++++ apps/web/src/routes/_chat.issues.tsx | 194 +++++++++++------- apps/web/src/routes/_chat.pull-requests.tsx | 3 - docs/user/source-control.md | 6 +- 14 files changed, 710 insertions(+), 329 deletions(-) create mode 100644 apps/web/src/components/issue/issuePanelPreferences.test.ts create mode 100644 apps/web/src/components/issue/issuePanelPreferences.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f40f8ff89310..b19f582aea18 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -214,7 +214,6 @@ import { usePreviewMiniPlayerStore, } from "../previewMiniPlayerStore"; import { IssueDetailPanel } from "./issue/IssueDetailPanel"; -import { IssuesPanel } from "./issue/IssuesPanel"; import { IssuesUnavailableState } from "./issue/IssuesUnavailableState"; import { pullRequestPanelContext } from "./pullRequest/pullRequestDetail.logic"; import { PullRequestDetailPanel } from "./pullRequest/PullRequestDetailPanel"; @@ -2036,8 +2035,10 @@ export default function ChatView(props: ChatViewProps) { const activeIssueSurfaceId = activeRightPanelSurface?.kind === "issue" ? activeRightPanelSurface.id - : activeRightPanelSurface?.kind === "issues" && activeRightPanelSurface.selected - ? issueSurfaceId(activeRightPanelSurface.selected) + : activeRightPanelSurface?.kind === "repository" && + activeRightPanelSurface.view === "issues" && + activeRightPanelSurface.selectedIssue + ? issueSurfaceId(activeRightPanelSurface.selectedIssue) : undefined; const handleIssueTabStatusChange = useCallback( (status: IssueTabStatus) => { @@ -4611,15 +4612,30 @@ export default function ChatView(props: ChatViewProps) { useRightPanelStore.getState().openRepository(activeThreadRef, "pull-requests"); }, [activeThreadRef, pullRequestsSurfaceAvailable]); const addRepositorySurface = useCallback(() => { - if (!activeThreadRef || (!(isGitRepo && supportsGitHistory) && !pullRequestsSurfaceAvailable)) + const issuesAvailable = supportsIssues && activeProject !== null; + if ( + !activeThreadRef || + (!(isGitRepo && supportsGitHistory) && !pullRequestsSurfaceAvailable && !issuesAvailable) + ) return; useRightPanelStore .getState() .openRepository( activeThreadRef, - isGitRepo && supportsGitHistory ? "history" : "pull-requests", + isGitRepo && supportsGitHistory + ? "history" + : pullRequestsSurfaceAvailable + ? "pull-requests" + : "issues", ); - }, [activeThreadRef, isGitRepo, pullRequestsSurfaceAvailable, supportsGitHistory]); + }, [ + activeProject, + activeThreadRef, + isGitRepo, + pullRequestsSurfaceAvailable, + supportsGitHistory, + supportsIssues, + ]); const { state: deviceState, loaded: deviceStateLoaded } = useDeviceState( activeThreadRef?.environmentId ?? null, ); @@ -4869,7 +4885,7 @@ export default function ChatView(props: ChatViewProps) { ) { panels.openProactive( activeThreadRef, - { id: "repository", kind: "repository", view: "pull-requests" }, + { id: "repository", kind: "repository", view: "pull-requests", selectedIssue: null }, userActionRevision, ); } else if ( @@ -5998,22 +6014,6 @@ export default function ChatView(props: ChatViewProps) { }, [composerOverlayElement], ); - // Which issue is not something the chooser can know, so it opens the browser and the reader - // picks inside it — in the same tab, rather than as one more of them. - const addIssueSurface = useCallback(() => { - if (!activeThreadRef) return; - useRightPanelStore.getState().openIssues(activeThreadRef); - }, [activeThreadRef]); - const selectIssueInPanel = useCallback( - ( - target: { projectId: string; provider?: string; repository: string; number: number } | null, - ) => { - if (!activeThreadRef) return; - useRightPanelStore.getState().selectIssueInPanel(activeThreadRef, target); - }, - [activeThreadRef], - ); - const issueSurfaceAvailable = supportsIssues && activeProject !== null; const publishComposerOverlayHeight = useCallback( (height: number) => { const nextHeight = Math.ceil(height); @@ -9761,10 +9761,29 @@ export default function ChatView(props: ChatViewProps) { environmentId={environmentId} cwd={isGitRepo ? gitCwd : null} threadRef={activeThreadRef} + issueContext={ + activeProject && activeProjectRef + ? { + projectId: activeProject.id, + handoffTarget: { + kind: "existing-thread", + projectRef: activeProjectRef, + draftId: composerDraftTarget, + }, + } + : null + } + selectedIssue={renderedRightPanelSurface.selectedIssue ?? null} view={renderedRightPanelSurface.view} active={rightPanelOpen} gitHistoryAvailable={supportsGitHistory && isGitRepo} gitHistoryPanelStore={gitHistoryPanelStore} + issuesAvailable={supportsIssues && activeProject !== null && activeProjectRef !== null} + onSelectIssue={(selected) => + useRightPanelStore.getState().selectRepositoryIssue(activeThreadRef, selected) + } + onIssueStateChange={handleIssueTabStatusChange} + onOpenLinkedPullRequest={openLinkedPullRequest} onViewChange={(view) => useRightPanelStore.getState().selectRepositoryView(activeThreadRef, view) } @@ -9823,12 +9842,10 @@ export default function ChatView(props: ChatViewProps) { } onOpenLinkedIssue={openLinkedIssue} /> - ) : (renderedRightPanelSurface?.kind === "issue" || - renderedRightPanelSurface?.kind === "issues") && + ) : renderedRightPanelSurface?.kind === "issue" && issuesSurfaceCapabilityState === "loading" ? ( - ) : (renderedRightPanelSurface?.kind === "issue" || - renderedRightPanelSurface?.kind === "issues") && + ) : renderedRightPanelSurface?.kind === "issue" && issuesSurfaceCapabilityState === "unavailable" ? ( - ) : renderedRightPanelSurface?.kind === "issues" && activeProject && activeProjectRef ? ( - ) : renderedRightPanelSurface?.kind === "agents" ? ( { it("moves through repository views with the tablist keys", () => { - expect(repositoryViewFromKey("history", "ArrowRight")).toBe("pull-requests"); - expect(repositoryViewFromKey("pull-requests", "ArrowLeft")).toBe("history"); + expect(repositoryViewFromKey("history", "ArrowRight")).toBe("issues"); + expect(repositoryViewFromKey("pull-requests", "ArrowLeft")).toBe("issues"); expect(repositoryViewFromKey("pull-requests", "Home")).toBe("history"); expect(repositoryViewFromKey("history", "End")).toBe("pull-requests"); }); diff --git a/apps/web/src/components/RepositoryPanel.tsx b/apps/web/src/components/RepositoryPanel.tsx index aa363e688f1b..bc6ef56e658a 100644 --- a/apps/web/src/components/RepositoryPanel.tsx +++ b/apps/web/src/components/RepositoryPanel.tsx @@ -1,15 +1,20 @@ -import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; +import type { EnvironmentId, ProjectId, ScopedThreadRef } from "@t3tools/contracts"; import { Activity, lazy, Suspense, useRef, useState, type KeyboardEvent } from "react"; -import type { RepositoryView } from "~/rightPanelStore"; +import type { IssueSelection, RepositoryView } from "~/rightPanelStore"; import type { GitHistoryPanelStore } from "./git-history/GitHistoryPanelState"; +import type { IssueHandoffTarget } from "./issue/IssueDetailPanel"; import { ThreadPullRequestsPanel } from "./pullRequest/ThreadPullRequestsPanel"; +import type { IssueTabStatus } from "./RightPanelTabs"; import { Button } from "./ui/button"; const GitHistoryPanel = lazy(() => import("./GitHistoryPanel")); +const IssuesPanel = lazy(() => + import("./issue/IssuesPanel").then(({ IssuesPanel }) => ({ default: IssuesPanel })), +); -const views = ["history", "pull-requests"] as const; +const views = ["history", "issues", "pull-requests"] as const; export function repositoryViewFromKey(view: RepositoryView, key: string): RepositoryView | null { const index = views.indexOf(view); @@ -20,25 +25,39 @@ export function repositoryViewFromKey(view: RepositoryView, key: string): Reposi return null; } -export default function RepositoryPanel(props: { +interface RepositoryPanelProps { readonly environmentId: EnvironmentId; readonly cwd: string | null; readonly threadRef: ScopedThreadRef; + readonly issueContext: { projectId: ProjectId; handoffTarget: IssueHandoffTarget } | null; + readonly selectedIssue: IssueSelection | null; readonly view: RepositoryView; readonly active: boolean; readonly gitHistoryAvailable: boolean; readonly gitHistoryPanelStore: GitHistoryPanelStore; + readonly issuesAvailable: boolean; readonly onViewChange: (view: RepositoryView) => void; -}) { + readonly onSelectIssue: (selected: IssueSelection | null) => void; + readonly onIssueStateChange: (status: IssueTabStatus) => void; + readonly onOpenLinkedPullRequest: (link: { + repository: string; + number: number; + url: string; + }) => void; +} + +export default function RepositoryPanel(props: RepositoryPanelProps) { const tabs = useRef>([]); const [historyActivated, setHistoryActivated] = useState(props.view === "history"); + const [issuesActivated, setIssuesActivated] = useState(props.view === "issues"); if (props.view === "history" && !historyActivated) setHistoryActivated(true); - const select = (view: RepositoryView) => props.onViewChange(view); - const onKeyDown = (event: KeyboardEvent, view: RepositoryView) => { + if (props.view === "issues" && !issuesActivated) setIssuesActivated(true); + + const move = (event: KeyboardEvent, view: RepositoryView) => { const next = repositoryViewFromKey(view, event.key); - if (next === null) return; + if (!next) return; event.preventDefault(); - select(next); + props.onViewChange(next); tabs.current[views.indexOf(next)]?.focus(); }; @@ -65,10 +84,10 @@ export default function RepositoryPanel(props: { aria-selected={props.view === view} aria-controls={`repository-panel-${view}`} tabIndex={props.view === view ? 0 : -1} - onClick={() => select(view)} - onKeyDown={(event) => onKeyDown(event, view)} + onClick={() => props.onViewChange(view)} + onKeyDown={(event) => move(event, view)} > - {view === "history" ? "History" : "Pull Requests"} + {view === "history" ? "History" : view === "issues" ? "Issues" : "Pull Requests"} ))} @@ -80,7 +99,7 @@ export default function RepositoryPanel(props: { aria-labelledby="repository-tab-history" className="min-h-0 flex-1" > - {props.gitHistoryAvailable && props.cwd !== null ? ( + {props.gitHistoryAvailable && props.cwd ? ( ) : null} + {issuesActivated ? ( + +
+ {props.issuesAvailable && props.issueContext ? ( + + + + ) : ( +
+ {props.issuesAvailable + ? "Open a project thread to browse issues." + : "Update the environment server to browse issues."} +
+ )} +
+
+ ) : null}
undefined} onAddRepository={() => undefined} repositoryAvailable={false} - onAddIssue={() => undefined} onAddDiff={() => undefined} onAddFiles={() => undefined} onAddAgents={() => undefined} @@ -132,8 +131,6 @@ function renderTabs( diffAvailable={false} filesAvailable={false} pullRequestAvailable={false} - issueAvailable={false} - pullRequestsAvailable={false} agentsAvailable={false} deviceAvailable={false} > diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index f92e89c20e7a..93e35c00d95d 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -45,7 +45,7 @@ import { import { isElectron } from "~/env"; import type { DesktopPreviewOverlay } from "~/previewStateStore"; -import { issueSurfaceId, type RightPanelSurface } from "~/rightPanelStore"; +import type { RightPanelSurface } from "~/rightPanelStore"; import { cn } from "~/lib/utils"; import { readLocalApi } from "~/localApi"; import { Button } from "~/components/ui/button"; @@ -126,12 +126,6 @@ interface RightPanelTabsProps { onAddFiles: () => void; onAddPullRequest: () => void; onAddAgents: () => void; - /** - * Picking an issue needs a project to pick from, which only a thread has: the list pages reuse - * these tabs to hold surfaces they opened themselves, so for them this card stays out. - */ - onAddIssue: () => void; - issueAvailable: boolean; onAddDevice: () => void; browserAvailable: boolean; terminalAvailable: boolean; @@ -139,7 +133,6 @@ interface RightPanelTabsProps { repositoryAvailable?: boolean | undefined; filesAvailable: boolean; pullRequestAvailable: boolean; - pullRequestsAvailable: boolean; agentsAvailable: boolean; deviceAvailable: boolean; pullRequestStatusSeeds?: Readonly>; @@ -178,9 +171,9 @@ const SURFACE_DISABLED_REASONS = { terminal: "Terminal surfaces are only available from a project thread.", files: "Files are only available when a project is open.", diff: "Diff is only available for server threads in Git repositories.", - gitHistory: "Repository is available for Git repositories or threads with linked pull requests.", + repository: + "Repository is available for Git repositories, project issues, or threads with linked pull requests.", pullRequest: "This thread's branch has no pull request yet.", - issue: "Issues are only available from a project checked out from a host.", agents: "Agents are only available from a thread.", device: "Devices are only available from a thread.", } as const; @@ -203,9 +196,8 @@ const SURFACE_UNAVAILABLE_HINTS = { terminal: "Available when a project is open.", files: "Available when a project is open.", diff: "Available for Git repositories.", - gitHistory: "Available for Git repositories or threads with linked pull requests.", + repository: "Available for Git repositories, project issues, or linked pull requests.", pullRequest: "No pull request on this branch yet.", - issue: "Available for projects with a host.", agents: "Available from a thread.", device: "Available from a thread.", } as const; @@ -346,7 +338,6 @@ function RightPanelEmptyState(props: { onAddRepository?: (() => void) | undefined; onAddFiles: () => void; onAddPullRequest: () => void; - onAddIssue: () => void; onAddAgents: () => void; onAddDevice: () => void; browserAvailable: boolean; @@ -355,8 +346,6 @@ function RightPanelEmptyState(props: { repositoryAvailable?: boolean | undefined; filesAvailable: boolean; pullRequestAvailable: boolean; - issueAvailable: boolean; - pullRequestsAvailable: boolean; agentsAvailable: boolean; deviceAvailable: boolean; liveAgentCount: number; @@ -403,11 +392,11 @@ function RightPanelEmptyState(props: { }, { label: "Repository", - description: "Browse history and linked pull requests.", + description: "Browse history, issues, and linked pull requests.", icon: GitBranch, shortcut: "H", available: props.repositoryAvailable ?? false, - disabledReason: SURFACE_UNAVAILABLE_HINTS.gitHistory, + disabledReason: SURFACE_UNAVAILABLE_HINTS.repository, onClick: props.onAddRepository ?? (() => undefined), badgeCount: 0, }, @@ -420,16 +409,6 @@ function RightPanelEmptyState(props: { onClick: props.onAddPullRequest, badgeCount: 0, }, - { - label: "Issue", - description: "Browse this project's issues.", - icon: CircleDot, - shortcut: "I", - available: props.issueAvailable, - disabledReason: SURFACE_UNAVAILABLE_HINTS.issue, - onClick: props.onAddIssue, - badgeCount: 0, - }, { label: "Agents", icon: Bot, @@ -670,9 +649,6 @@ function surfaceTitle( case "pull-request": case "issue": return `#${surface.number}`; - // The strip says what the tab is showing, which for the browser is either of two things. - case "issues": - return surface.selected ? `#${surface.selected.number}` : "Issues"; case "agents": return "Agents"; case "device": @@ -760,17 +736,11 @@ function SurfaceIcon({ seed={pullRequestStatusSeeds?.[surface.id]} /> ); - case "issue": - case "issues": { + case "issue": { // Until the panel has read the issue, the tab wears the neutral glyph rather than // claiming a state it has not been told. The browser wears the state of whichever issue // it is showing, and the plain glyph while it is listing. - const statusKey = - surface.kind === "issue" - ? surface.id - : surface.selected - ? issueSurfaceId(surface.selected) - : null; + const statusKey = surface.id; const state = (statusKey === null ? null : issueStatuses?.[statusKey]) ?? null; const presentation = state === null ? null : resolveIssueState(state); const Icon = presentation?.Icon ?? CircleDot; @@ -973,7 +943,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { icon: GitBranch, shortcut: "H", available: props.repositoryAvailable ?? false, - disabledReason: SURFACE_DISABLED_REASONS.gitHistory, + disabledReason: SURFACE_DISABLED_REASONS.repository, onClick: props.onAddRepository ?? (() => undefined), }, { @@ -984,14 +954,6 @@ export function RightPanelTabs(props: RightPanelTabsProps) { disabledReason: SURFACE_DISABLED_REASONS.pullRequest, onClick: props.onAddPullRequest, }, - { - label: "Issue", - icon: CircleDot, - shortcut: "I", - available: props.issueAvailable, - disabledReason: SURFACE_DISABLED_REASONS.issue, - onClick: props.onAddIssue, - }, { label: "Agents", icon: Bot, @@ -1482,7 +1444,6 @@ export function RightPanelTabs(props: RightPanelTabsProps) { onAddRepository={props.onAddRepository} onAddFiles={props.onAddFiles} onAddPullRequest={props.onAddPullRequest} - onAddIssue={props.onAddIssue} onAddAgents={props.onAddAgents} onAddDevice={props.onAddDevice} browserAvailable={props.browserAvailable} @@ -1491,8 +1452,6 @@ export function RightPanelTabs(props: RightPanelTabsProps) { repositoryAvailable={props.repositoryAvailable} filesAvailable={props.filesAvailable} pullRequestAvailable={props.pullRequestAvailable} - issueAvailable={props.issueAvailable} - pullRequestsAvailable={props.pullRequestsAvailable} agentsAvailable={props.agentsAvailable} deviceAvailable={props.deviceAvailable} liveAgentCount={props.liveAgentCount} diff --git a/apps/web/src/components/issue/IssuesPanel.tsx b/apps/web/src/components/issue/IssuesPanel.tsx index a5aea404f10e..f260512041de 100644 --- a/apps/web/src/components/issue/IssuesPanel.tsx +++ b/apps/web/src/components/issue/IssuesPanel.tsx @@ -1,6 +1,7 @@ import type { EnvironmentId, IssueInvolvement, + IssueLinkedPullRequest, IssueListOrder, IssueListSort, IssueListEntry, @@ -19,7 +20,8 @@ import { } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import type { IssuesSurface } from "~/rightPanelStore"; +import type { RepositoryItemSelection } from "~/rightPanelStore"; +import { useLocalStorage } from "~/hooks/useLocalStorage"; import { issueEnvironment } from "~/state/issues"; import { useDebouncedValue } from "~/state/queries"; import { useEnvironmentQuery } from "~/state/query"; @@ -40,6 +42,12 @@ import { IssueFiltersMenu, IssueSortMenu } from "./IssueListFilters"; import { ListSearchInput, type ListFilterOption } from "../sourceControl/ListFilterMenu"; import { IssueRow } from "./IssueRow"; import { IssuesUnavailableState } from "./IssuesUnavailableState"; +import { + DEFAULT_ISSUE_PANEL_PREFERENCES, + issuePanelPreferencesKey, + IssuePanelPreferencesSchema, + normalizeIssueSearchQuery, +} from "./issuePanelPreferences"; // The same vocabulary the issues page filters by, minus the two questions a panel already knows // the answer to: it lists one project, on one host. @@ -77,11 +85,6 @@ interface PanelPage { } const SEARCH_DEBOUNCE_MS = 250; -/** - * What `IssueListInput` accepts as a query. Past it the read is refused outright, so a pasted wall - * of text searches its opening rather than coming back as an error about its length. - */ -const MAX_QUERY_LENGTH = 200; const PAGE_SIZE = 30; /** The listing's own ceiling. Past it the search is the way to find something, not more rows. */ const MAX_LIMIT = 500; @@ -90,11 +93,12 @@ interface IssuesPanelProps { environmentId: EnvironmentId; /** The thread's project, which is the only repository this panel lists. */ projectId: ProjectId; - selected: IssuesSurface["selected"]; + selected: RepositoryItemSelection | null; /** Null returns the panel to the list it was picked from. */ - onSelect: (target: NonNullable | null) => void; + onSelect: (target: RepositoryItemSelection | null) => void; handoffTarget: IssueHandoffTarget; onStateChange: (status: IssueTabStatus) => void; + onOpenLinkedPullRequest?: (link: IssueLinkedPullRequest) => void; } /** @@ -117,18 +121,18 @@ function ProjectIssues({ onSelect, handoffTarget, onStateChange, + onOpenLinkedPullRequest, }: IssuesPanelProps) { // Held here rather than in the list, so reading an issue and coming back does not throw away // the search that found it — the list is unmounted while the issue is open. + const [preferences, setPreferences] = useLocalStorage( + issuePanelPreferencesKey(environmentId, projectId), + DEFAULT_ISSUE_PANEL_PREFERENCES, + IssuePanelPreferencesSchema, + ); const [query, setQuery] = useState(""); const [page, setPage] = useState({ key: "", size: PAGE_SIZE, cursors: null }); - const [filters, setFilters] = useState<{ - readonly state: IssueListState; - readonly involvement: IssueInvolvement; - readonly label: string | undefined; - readonly sort: IssueListSort; - readonly order: IssueListOrder; - }>({ state: "open", involvement: "all", label: undefined, sort: "updated", order: "desc" }); + const filters: PanelFilters = { ...preferences, label: preferences.label }; if (selected) { return ( @@ -158,6 +162,7 @@ function ProjectIssues({ }} handoffTarget={handoffTarget} onStateChange={onStateChange} + {...(onOpenLinkedPullRequest ? { onOpenLinkedPullRequest } : {})} // The panel is the narrowest place this reads, so the metadata folds into the top row // once the content scrolls — the same bargain the issues page makes. chromeVariant="collapse" @@ -176,7 +181,7 @@ function ProjectIssues({ page={page} onPage={setPage} filters={filters} - onFilters={setFilters} + onFilters={(nextFilters) => setPreferences((current) => ({ ...current, ...nextFilters }))} /> ); } @@ -194,7 +199,7 @@ function IssueBrowserList({ }: { environmentId: EnvironmentId; projectId: ProjectId; - onSelect: (target: NonNullable) => void; + onSelect: (target: RepositoryItemSelection) => void; query: string; onQuery: (query: string) => void; page: PanelPage; @@ -202,10 +207,10 @@ function IssueBrowserList({ filters: PanelFilters; onFilters: (filters: PanelFilters) => void; }) { - const typed = query.trim().slice(0, MAX_QUERY_LENGTH); + const searchQuery = normalizeIssueSearchQuery(query); // Searching asks the host, which takes a round trip, so the text is held for a moment before it // is sent — the same bargain the issues page makes. - const sent = useDebouncedValue(typed, SEARCH_DEBOUNCE_MS); + const sent = useDebouncedValue(searchQuery, SEARCH_DEBOUNCE_MS); // The label is narrowed on the rows rather than on the host, so it is no part of the question // and no reason to start the list again. @@ -272,7 +277,7 @@ function IssueBrowserList({ const hostOrdered = filters.sort === "best-match" && answered.providers.some((provider) => !provider.sorts.includes("best-match")) - ? rankIssueMatches(answered.entries, sent) + ? rankIssueMatches(answered.entries, searchQuery) : answered.entries; setOrdered((previous) => { if (previous === null || previous.key !== filterKey || sentCursors === null) { @@ -292,7 +297,7 @@ function IssueBrowserList({ viewers: answered.viewers, }; }); - }, [answered, filterKey, filters.order, filters.sort, sent, sentCursors]); + }, [answered, filterKey, filters.order, filters.sort, searchQuery, sentCursors]); // Involvement and the label are narrowed here as well as asked for: a host that cannot express // "mentioned" answers unnarrowed, and no host is asked about a label at all. @@ -306,8 +311,8 @@ function IssueBrowserList({ ); const queried = filterIssueQueryResults( byInvolvement, - typed, - typed === sent && !listQuery.isPending, + searchQuery, + searchQuery === sent && !listQuery.isPending, searchingHosts, ); return filters.label === undefined @@ -322,7 +327,7 @@ function IssueBrowserList({ ordered, searchingHosts, sent, - typed, + searchQuery, ]); /** From what is held rather than from the read in flight, which has not answered yet. */ @@ -413,7 +418,7 @@ function IssueBrowserList({ 0 && (typed !== sent || listQuery.isPending)} + busy={searchQuery.length > 0 && (searchQuery !== sent || listQuery.isPending)} onChange={onQuery} />
@@ -446,7 +451,7 @@ function IssueBrowserList({ ) : entries.length === 0 ? (

- {typed.length > 0 + {searchQuery.length > 0 ? "No issue here matches that." : narrowed ? "No issue here matches these filters." diff --git a/apps/web/src/components/issue/issuePanelPreferences.test.ts b/apps/web/src/components/issue/issuePanelPreferences.test.ts new file mode 100644 index 000000000000..a394bdf3f249 --- /dev/null +++ b/apps/web/src/components/issue/issuePanelPreferences.test.ts @@ -0,0 +1,107 @@ +import * as Schema from "effect/Schema"; +import { IssueListInput, type EnvironmentId, type ProjectId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + DEFAULT_ISSUE_PANEL_PREFERENCES, + issuePanelPreferencePatch, + issuePanelPreferencesKey, + IssuePanelPreferencesSchema, + normalizeIssueSearchQuery, + resolveIssuePanelPreferences, +} from "./issuePanelPreferences"; + +const ENVIRONMENT_1 = "environment-1" as EnvironmentId; +const ENVIRONMENT_2 = "environment-2" as EnvironmentId; +const PROJECT_1 = "project-1" as ProjectId; +const PROJECT_2 = "project-2" as ProjectId; +const IssuePanelPreferencesJson = Schema.fromJsonString(IssuePanelPreferencesSchema); +const decodeIssuePanelPreferences = Schema.decodeSync(IssuePanelPreferencesJson); +const encodeIssuePanelPreferences = Schema.encodeSync(IssuePanelPreferencesJson); +const decodeUnknownIssuePanelPreferences = Schema.decodeUnknownOption(IssuePanelPreferencesSchema); +const decodeIssueListInput = Schema.decodeUnknownSync(IssueListInput); + +describe("issue panel preferences", () => { + it("uses a separate storage key for each environment and project", () => { + expect(issuePanelPreferencesKey(ENVIRONMENT_1, PROJECT_1)).not.toBe( + issuePanelPreferencesKey(ENVIRONMENT_2, PROJECT_2), + ); + expect(issuePanelPreferencesKey(ENVIRONMENT_1, PROJECT_1)).not.toBe( + issuePanelPreferencesKey(ENVIRONMENT_2, PROJECT_1), + ); + expect(issuePanelPreferencesKey(ENVIRONMENT_1, PROJECT_1)).not.toBe( + issuePanelPreferencesKey(ENVIRONMENT_1, PROJECT_2), + ); + }); + + it("round-trips all saved filters", () => { + const preferences = { + ...DEFAULT_ISSUE_PANEL_PREFERENCES, + state: "closed" as const, + involvement: "mentioned" as const, + label: "status/fixed-in-branch", + sort: "created" as const, + order: "asc" as const, + }; + + expect(decodeIssuePanelPreferences(encodeIssuePanelPreferences(preferences))).toEqual( + preferences, + ); + }); + + it("hydrates absent route filters from the shared preferences", () => { + const preferences = { + ...DEFAULT_ISSUE_PANEL_PREFERENCES, + state: "closed" as const, + involvement: "mentioned" as const, + label: "status/fixed-in-branch", + sort: "created" as const, + order: "asc" as const, + }; + + expect(resolveIssuePanelPreferences(preferences, {})).toEqual(preferences); + }); + + it("keeps explicit route filters over shared preferences", () => { + expect( + resolveIssuePanelPreferences( + { ...DEFAULT_ISSUE_PANEL_PREFERENCES, state: "closed", label: "status/fixed" }, + { state: "open", label: "status/picked" }, + ), + ).toMatchObject({ state: "open", label: "status/picked" }); + }); + + it("writes route filter changes without treating query edits as preferences", () => { + expect(issuePanelPreferencePatch({ state: "closed", sort: "created" })).toEqual({ + state: "closed", + sort: "created", + }); + expect(issuePanelPreferencePatch({ query: "exact search text" })).toBeNull(); + }); + + it("normalizes search text before it reaches the issue-list contract", () => { + const atLimit = "x".repeat(200); + const overLimit = `${atLimit}x`; + + expect(normalizeIssueSearchQuery(atLimit)).toBe(atLimit); + expect(normalizeIssueSearchQuery(overLimit)).toBe(atLimit); + expect(normalizeIssueSearchQuery(` ${atLimit}`)).toBe(atLimit); + expect(normalizeIssueSearchQuery(" ")).toBe(""); + expect( + decodeIssueListInput({ state: "open", query: normalizeIssueSearchQuery(overLimit) }), + ).toEqual({ + state: "open", + query: atLimit, + }); + expect(decodeIssueListInput({ state: "open" })).toEqual({ state: "open" }); + }); + + it("rejects corrupt preferences", () => { + const corrupt: unknown = { + ...DEFAULT_ISSUE_PANEL_PREFERENCES, + label: "x".repeat(201), + }; + + expect(decodeUnknownIssuePanelPreferences(corrupt)._tag).toBe("None"); + }); +}); diff --git a/apps/web/src/components/issue/issuePanelPreferences.ts b/apps/web/src/components/issue/issuePanelPreferences.ts new file mode 100644 index 000000000000..87d9f6203d51 --- /dev/null +++ b/apps/web/src/components/issue/issuePanelPreferences.ts @@ -0,0 +1,77 @@ +import * as Schema from "effect/Schema"; + +import { + IssueInvolvement, + IssueListOrder, + IssueListSort, + IssueListState, + type EnvironmentId, + type ProjectId, +} from "@t3tools/contracts"; + +const BoundedLabel = Schema.String.check(Schema.isMaxLength(200)); +const MAX_ISSUE_SEARCH_QUERY_LENGTH = 200; + +export function normalizeIssueSearchQuery(query: string): string { + return query.trim().slice(0, MAX_ISSUE_SEARCH_QUERY_LENGTH); +} + +export const IssuePanelPreferencesSchema = Schema.Struct({ + state: IssueListState, + involvement: IssueInvolvement, + label: Schema.optional(BoundedLabel), + sort: IssueListSort, + order: IssueListOrder, +}); +export type IssuePanelPreferences = typeof IssuePanelPreferencesSchema.Type; + +export interface IssuePanelPreferencePatch { + readonly state?: IssueListState | undefined; + readonly involvement?: IssueInvolvement | undefined; + readonly label?: string | undefined; + readonly sort?: IssueListSort | undefined; + readonly order?: IssueListOrder | undefined; +} + +export const DEFAULT_ISSUE_PANEL_PREFERENCES: IssuePanelPreferences = { + state: "open", + involvement: "all", + sort: "updated", + order: "desc", +}; + +export function resolveIssuePanelPreferences( + preferences: IssuePanelPreferences, + overrides: IssuePanelPreferencePatch, +): IssuePanelPreferences { + return { + state: overrides.state ?? preferences.state, + involvement: overrides.involvement ?? preferences.involvement, + label: "label" in overrides ? overrides.label : preferences.label, + sort: overrides.sort ?? preferences.sort, + order: overrides.order ?? preferences.order, + }; +} + +export function issuePanelPreferencePatch( + input: IssuePanelPreferencePatch & { + readonly query?: string | undefined; + readonly projectId?: ProjectId | undefined; + }, +): IssuePanelPreferencePatch | null { + const patch: IssuePanelPreferencePatch = { + ...("state" in input ? { state: input.state } : {}), + ...("involvement" in input ? { involvement: input.involvement } : {}), + ...("label" in input ? { label: input.label } : {}), + ...("sort" in input ? { sort: input.sort } : {}), + ...("order" in input ? { order: input.order } : {}), + }; + return Object.keys(patch).length === 0 ? null : patch; +} + +export function issuePanelPreferencesKey( + environmentId: EnvironmentId, + projectId: ProjectId, +): string { + return `t3.issues.panel.preferences:${JSON.stringify([environmentId, projectId])}`; +} diff --git a/apps/web/src/rightPanelStore.test.ts b/apps/web/src/rightPanelStore.test.ts index 008ca5cc0414..cded5d099331 100644 --- a/apps/web/src/rightPanelStore.test.ts +++ b/apps/web/src/rightPanelStore.test.ts @@ -140,6 +140,7 @@ describe("rightPanelStore", () => { id: "repository", kind: "repository", view: "pull-requests", + selectedIssue: null, }); }); @@ -159,11 +160,11 @@ describe("rightPanelStore", () => { id: "repository", kind: "repository", view: "pull-requests", + selectedIssue: null, }); }); it.each([ - { choice: "issues", choose: () => useRightPanelStore.getState().openIssues(refA) }, { choice: "issue", choose: () => @@ -171,10 +172,6 @@ describe("rightPanelStore", () => { .getState() .openIssue(refA, { projectId: "project-a", repository: "pingdotgg/t3code", number: 1 }), }, - { - choice: "issue selection", - choose: () => useRightPanelStore.getState().selectIssueInPanel(refA, null), - }, { choice: "file", choose: () => useRightPanelStore.getState().openFile(refA, "src/app.ts") }, { choice: "pull request", @@ -485,6 +482,54 @@ describe("rightPanelStore", () => { ]); }); + it("merges a selected standalone Issues tab into Repository in its original position", () => { + expect( + migratePersistedRightPanelState({ + byThreadKey: { + "env-1:thread-A": { + isOpen: true, + activeSurfaceId: "issues", + surfaces: [ + { id: "files", kind: "files" }, + { id: "repository", kind: "repository", view: "history" }, + { + id: "issues", + kind: "issues", + selected: { + projectId: "project-a", + provider: "linear", + repository: "ENG", + number: 12224, + }, + }, + ], + }, + }, + }), + ).toEqual({ + byThreadKey: { + "env-1:thread-A": { + isOpen: true, + activeSurfaceId: "repository", + surfaces: [ + { id: "files", kind: "files" }, + { + id: "repository", + kind: "repository", + view: "issues", + selectedIssue: { + projectId: "project-a", + provider: "linear", + repository: "ENG", + number: 12224, + }, + }, + ], + }, + }, + }); + }); + it("drops malformed persisted surfaces", () => { expect( migratePersistedRightPanelState({ @@ -1021,59 +1066,63 @@ describe("rightPanelStore", () => { ); }); - it("keeps the issue browser one tab while the issue it shows changes", () => { + it("keeps an issue selection while Repository changes views", () => { const target = { projectId: "project-a", provider: "github", repository: "pingdotgg/t3code", number: 4909, }; - useRightPanelStore.getState().openIssues(refA); + const store = useRightPanelStore.getState(); + store.openRepository(refA, "issues"); + store.selectRepositoryIssue(refA, target); + store.openRepository(refA, "history"); + expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ isOpen: true, - activeSurfaceId: "issues", - surfaces: [{ id: "issues", kind: "issues", selected: null }], + activeSurfaceId: "repository", + surfaces: [{ id: "repository", kind: "repository", view: "history", selectedIssue: target }], }); - useRightPanelStore.getState().selectIssueInPanel(refA, target); - // Reopening from the chooser must not throw away what the reader is reading. - useRightPanelStore.getState().openIssues(refA); + const revision = store.getUserActionRevision(refA); + expect( + store.openProactive( + refA, + { id: "repository", kind: "repository", view: "pull-requests" }, + revision, + ), + ).toBe(true); expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ isOpen: true, - activeSurfaceId: "issues", - surfaces: [{ id: "issues", kind: "issues", selected: target }], - }); - - useRightPanelStore.getState().selectIssueInPanel(refA, null); - expect(selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ - id: "issues", - kind: "issues", - selected: null, + activeSurfaceId: "repository", + surfaces: [ + { id: "repository", kind: "repository", view: "pull-requests", selectedIssue: target }, + ], }); }); - it("leaves per-issue surfaces alone when the browser's issue changes", () => { + it("leaves per-issue surfaces alone when Repository selects an issue", () => { const opened = { projectId: "project-a", repository: "pingdotgg/t3code", number: 4909 }; const browsed = { projectId: "project-a", repository: "pingdotgg/t3code", number: 4910 }; useRightPanelStore.getState().openIssue(refA, opened); - useRightPanelStore.getState().openIssues(refA); - useRightPanelStore.getState().selectIssueInPanel(refA, browsed); + useRightPanelStore.getState().openRepository(refA, "issues"); + useRightPanelStore.getState().selectRepositoryIssue(refA, browsed); const state = selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA); expect(state.surfaces).toEqual([ { id: issueSurfaceId(opened), kind: "issue", ...opened }, - { id: "issues", kind: "issues", selected: browsed }, + { id: "repository", kind: "repository", view: "issues", selectedIssue: browsed }, ]); - expect(state.activeSurfaceId).toBe("issues"); + expect(state.activeSurfaceId).toBe("repository"); }); - it("forgets a persisted browser selection that no longer names an issue", () => { + it("forgets a malformed persisted issue selection", () => { expect( migratePersistedRightPanelState({ byThreadKey: { "env-1:thread-A": { isOpen: true, - activeSurfaceId: "issues", + activeSurfaceId: "repository", surfaces: [ { id: "issues", kind: "issues", selected: { repository: "pingdotgg/t3code" } }, ], @@ -1084,8 +1133,47 @@ describe("rightPanelStore", () => { byThreadKey: { "env-1:thread-A": { isOpen: true, - activeSurfaceId: "issues", - surfaces: [{ id: "issues", kind: "issues", selected: null }], + activeSurfaceId: "repository", + surfaces: [ + { id: "repository", kind: "repository", view: "history", selectedIssue: null }, + ], + }, + }, + }); + }); + + it("keeps a repository issue selection when a legacy Issues surface has no selection", () => { + const selectedIssue = { + projectId: "project-a", + provider: "github", + repository: "pingdotgg/t3code", + number: 4909, + }; + + expect( + migratePersistedRightPanelState({ + byThreadKey: { + "env-1:thread-A": { + isOpen: true, + activeSurfaceId: "issues", + surfaces: [ + { + id: "repository", + kind: "repository", + view: "history", + selectedIssue, + }, + { id: "issues", kind: "issues", selected: null }, + ], + }, + }, + }), + ).toEqual({ + byThreadKey: { + "env-1:thread-A": { + isOpen: true, + activeSurfaceId: "repository", + surfaces: [{ id: "repository", kind: "repository", view: "issues", selectedIssue }], }, }, }); diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts index f8907714f8fb..dc708b8fba3e 100644 --- a/apps/web/src/rightPanelStore.ts +++ b/apps/web/src/rightPanelStore.ts @@ -31,12 +31,18 @@ const RIGHT_PANEL_KINDS = [ "terminal", "pull-request", "issue", - "issues", "agents", ] as const; export type RightPanelKind = (typeof RIGHT_PANEL_KINDS)[number]; -export type RepositoryView = "history" | "pull-requests"; +export type RepositoryView = "history" | "issues" | "pull-requests"; +export type RepositoryItemSelection = { + projectId: string; + provider?: string; + repository: string; + number: number; +}; +export type IssueSelection = RepositoryItemSelection; export interface DeviceTabTarget { hostId: string; @@ -58,7 +64,12 @@ export type RightPanelSurface = splitDirection?: "horizontal" | "vertical"; } | { id: "diff"; kind: "diff" } - | { id: "repository"; kind: "repository"; view: RepositoryView } + | { + id: "repository"; + kind: "repository"; + view: RepositoryView; + selectedIssue?: IssueSelection | null; + } | { id: "files"; kind: "files" } | { id: `file:${string}` | `attachment:${string}`; @@ -104,15 +115,6 @@ export type RightPanelSurface = repository: string; number: number; } - | { - /** - * The issue browser: one per thread, like the agents surface. It shows the project's issues, - * or the one issue picked out of them — picking changes this tab rather than adding one. - */ - id: "issues"; - kind: "issues"; - selected: { projectId: string; provider?: string; repository: string; number: number } | null; - } | { id: "agents"; kind: "agents" }; const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2"; @@ -122,7 +124,8 @@ const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2"; // v12 adds the device and issue surfaces. // v13 adds the issues browser surface and stops persisting the issues list panel. // v14 combines Git History and linked pull requests into Repository. -const RIGHT_PANEL_STORAGE_VERSION = 14; +// v15 adds Issues to Repository. +const RIGHT_PANEL_STORAGE_VERSION = 15; const PersistedRightPanelState = Schema.Struct({ byThreadKey: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)), @@ -156,7 +159,8 @@ const PersistedRightPanelSurface = Schema.Union([ Schema.Struct({ id: Schema.Literal("repository"), kind: Schema.Literal("repository"), - view: Schema.Literals(["history", "pull-requests"]), + view: Schema.Literals(["history", "issues", "pull-requests"]), + selectedIssue: Schema.optionalKey(Schema.Unknown), }), Schema.Struct({ id: Schema.Literal("browser:new"), @@ -272,7 +276,7 @@ interface RightPanelStoreState { ) => boolean; open: ( ref: ScopedThreadRef, - kind: Exclude, + kind: Exclude, ) => void; openDevice: (ref: ScopedThreadRef, target: DeviceTabTarget, automatic?: boolean) => void; renameDevice: (ref: ScopedThreadRef, surfaceId: string, title: string) => void; @@ -300,14 +304,10 @@ interface RightPanelStoreState { number: number; }, ) => void; - openIssues: (ref: ScopedThreadRef) => void; openRepository: (ref: ScopedThreadRef, view: RepositoryView) => void; selectRepositoryView: (ref: ScopedThreadRef, view: RepositoryView) => void; /** What the issue browser is showing: an issue, or null for the list it was picked from. */ - selectIssueInPanel: ( - ref: ScopedThreadRef, - target: { projectId: string; provider?: string; repository: string; number: number } | null, - ) => void; + selectRepositoryIssue: (ref: ScopedThreadRef, target: IssueSelection | null) => void; openTerminal: (ref: ScopedThreadRef, terminalId: string) => void; splitTerminal: ( ref: ScopedThreadRef, @@ -329,7 +329,7 @@ interface RightPanelStoreState { toggleVisibility: (ref: ScopedThreadRef) => void; toggle: ( ref: ScopedThreadRef, - kind: Exclude, + kind: Exclude, ) => void; removeThread: (ref: ScopedThreadRef) => void; } @@ -341,16 +341,13 @@ const EMPTY_THREAD_STATE: ThreadRightPanelState = { }; const singletonSurface = ( - kind: Exclude< - RightPanelKind, - "file" | "preview" | "terminal" | "pull-request" | "issue" | "issues" - >, + kind: Exclude, ): RightPanelSurface => { switch (kind) { case "diff": return { id: "diff", kind }; case "repository": - return { id: "repository", kind, view: "history" }; + return { id: "repository", kind, view: "history", selectedIssue: null }; case "files": return { id: "files", kind }; case "agents": @@ -469,7 +466,9 @@ function issueSurface(target: { }; } -export type IssuesSurface = Extract; +function normalizeIssueSelection(value: unknown): IssueSelection | null { + return Option.getOrElse(decodePersistedIssueSelection(value), () => null); +} export function updateIssueTabStatus( statuses: Readonly>, @@ -580,23 +579,50 @@ export function migratePersistedRightPanelState(persistedState: unknown): { (): PersistedThreadRightPanelState => ({}), ); const rawActiveSurfaceId = threadState.activeSurfaceId; - const rawSurfaces = threadState.surfaces ?? []; + const persistedSurfaces = (threadState.surfaces ?? []).flatMap((rawSurface) => + Option.toArray(decodePersistedRightPanelSurface(rawSurface)), + ); + const persistedRepository = persistedSurfaces.find( + (surface) => surface.kind === "repository", + ); + const persistedIssues = persistedSurfaces.find((surface) => surface.kind === "issues"); + const persistedRepositoryView = + persistedRepository?.kind === "repository" ? persistedRepository.view : "history"; const repositoryView = - rawActiveSurfaceId === "pull-requests" ? "pull-requests" : "history"; + rawActiveSurfaceId === "issues" + ? "issues" + : rawActiveSurfaceId === "pull-requests" + ? "pull-requests" + : rawActiveSurfaceId === "git-history" + ? "history" + : persistedRepositoryView; + const issueSelectionSource = + (persistedIssues?.kind === "issues" ? persistedIssues.selected : undefined) ?? + (persistedRepository?.kind === "repository" + ? persistedRepository.selectedIssue + : undefined); + const repositorySurface: RightPanelSurface = { + id: "repository", + kind: "repository", + view: repositoryView, + ...(persistedIssues !== undefined || issueSelectionSource !== undefined + ? { selectedIssue: normalizeIssueSelection(issueSelectionSource) } + : {}), + }; let repositoryIncluded = false; - const surfaces = rawSurfaces.flatMap((rawSurface) => { - const surface = Option.getOrElse( - decodePersistedRightPanelSurface(rawSurface), - (): PersistedRightPanelSurface | null => null, - ); - if (surface === null) return []; + const surfaces = persistedSurfaces.flatMap((surface) => { // Dropped surface kind: plans now render inline in the // transcript (v9). if (surface.kind === "plan") return []; - if (surface.kind === "git-history" || surface.kind === "pull-requests") { + if ( + surface.kind === "git-history" || + surface.kind === "pull-requests" || + surface.kind === "repository" || + surface.kind === "issues" + ) { if (repositoryIncluded) return []; repositoryIncluded = true; - return [{ id: "repository", kind: "repository", view: repositoryView }]; + return [repositorySurface]; } if (surface.kind === "file") { const revealLine = @@ -659,21 +685,8 @@ export function migratePersistedRightPanelState(persistedState: unknown): { }), ]; } - if (surface.kind === "issues") { - return [ - { - id: "issues", - kind: "issues", - selected: Option.getOrElse( - decodePersistedIssueSelection(surface.selected), - () => null, - ), - }, - ]; - } if (surface.kind === "diff" || surface.kind === "files" || surface.kind === "agents") return [surface]; - if (surface.kind === "repository") return [surface]; if (surface.kind === "preview") return [surface]; if (surface.kind === "device") return [surface]; if (surface.id !== `terminal:${surface.resourceId}`) return []; @@ -704,7 +717,9 @@ export function migratePersistedRightPanelState(persistedState: unknown): { ? (rawActiveSurfaceId ?? null) : rawActiveSurfaceId === "pull-request" ? (surfaces.find((surface) => surface.kind === "pull-request")?.id ?? null) - : rawActiveSurfaceId === "git-history" || rawActiveSurfaceId === "pull-requests" + : rawActiveSurfaceId === "git-history" || + rawActiveSurfaceId === "pull-requests" || + rawActiveSurfaceId === "issues" ? (surfaces.find((surface) => surface.kind === "repository")?.id ?? null) : null; // A migration that dropped every surface (e.g. plan-only panels @@ -764,19 +779,26 @@ export const useRightPanelStore = create()( return state; } opened = true; - return automaticUpdate(state, threadKey, (current) => - surface.kind === "repository" - ? upsertSurface( - { - ...current, - surfaces: current.surfaces.map((entry) => - entry.id === surface.id ? surface : entry, - ), - }, - surface, - ) - : upsertSurface(current, surface), - ); + return automaticUpdate(state, threadKey, (current) => { + if (surface.kind !== "repository") return upsertSurface(current, surface); + const existing = current.surfaces.find( + (entry): entry is Extract => + entry.kind === "repository", + ); + const repositorySurface = { + ...surface, + selectedIssue: surface.selectedIssue ?? existing?.selectedIssue ?? null, + }; + return upsertSurface( + { + ...current, + surfaces: current.surfaces.map((entry) => + entry.id === repositorySurface.id ? repositorySurface : entry, + ), + }, + repositorySurface, + ); + }); }); return opened; }, @@ -855,12 +877,6 @@ export const useRightPanelStore = create()( upsertSurface(current, issueSurface(target)), ), ), - openIssues: (ref) => - set((state) => - userAction(state, scopedThreadKey(ref), (current) => - upsertSurface(current, { id: "issues", kind: "issues", selected: null }), - ), - ), openRepository: (ref, view) => set((state) => userAction(state, scopedThreadKey(ref), (current) => { @@ -868,7 +884,12 @@ export const useRightPanelStore = create()( (surface): surface is Extract => surface.kind === "repository", ); - const surface = { id: "repository" as const, kind: "repository" as const, view }; + const surface = { + id: "repository" as const, + kind: "repository" as const, + view, + selectedIssue: existing?.selectedIssue ?? null, + }; return upsertSurface( { ...current, @@ -889,12 +910,12 @@ export const useRightPanelStore = create()( ), })), ), - selectIssueInPanel: (ref, target) => + selectRepositoryIssue: (ref, target) => set((state) => userAction(state, scopedThreadKey(ref), (current) => ({ ...current, surfaces: current.surfaces.map((surface) => - surface.kind === "issues" ? { ...surface, selected: target } : surface, + surface.kind === "repository" ? { ...surface, selectedIssue: target } : surface, ), })), ), diff --git a/apps/web/src/routes/-chatIssuesTitlebar.test.tsx b/apps/web/src/routes/-chatIssuesTitlebar.test.tsx index 7b81cdc969f9..58627290d3a7 100644 --- a/apps/web/src/routes/-chatIssuesTitlebar.test.tsx +++ b/apps/web/src/routes/-chatIssuesTitlebar.test.tsx @@ -11,14 +11,51 @@ import { MenuItem, MenuRadioItem } from "../components/ui/menu"; import { CompactFilterMenu, hasLinearManagementState, + issueListScopePreferencePatch, issueSelectionSearchPatch, isIssueEntryOpen, IssuesColumn, mergeIssueProviderSummaries, stabilizeLinearProviderSummary, } from "./_chat.issues"; +import { + DEFAULT_ISSUE_PANEL_PREFERENCES, + resolveIssuePanelPreferences, +} from "../components/issue/issuePanelPreferences"; describe("IssuesColumn", () => { + it("saves explicit route filter choices without saving searches or project switches", () => { + const saved = { + ...DEFAULT_ISSUE_PANEL_PREFERENCES, + sort: "created" as const, + order: "asc" as const, + }; + + expect( + resolveIssuePanelPreferences(saved, issueListScopePreferencePatch({ state: "closed" }) ?? {}), + ).toMatchObject({ state: "closed", sort: "created", order: "asc" }); + expect( + resolveIssuePanelPreferences( + saved, + issueListScopePreferencePatch({ sort: undefined }, { sort: "updated" }) ?? {}, + ), + ).toMatchObject({ sort: "updated", order: "asc" }); + expect( + resolveIssuePanelPreferences( + saved, + issueListScopePreferencePatch({ order: undefined }, { order: "desc" }) ?? {}, + ), + ).toMatchObject({ sort: "created", order: "desc" }); + expect(issueListScopePreferencePatch({ q: " exact search text " })).toBeNull(); + expect( + issueListScopePreferencePatch( + { projectId: "project_1" as ProjectId, sort: undefined, order: undefined }, + { sort: "updated", order: "desc" }, + ), + ).toBeNull(); + expect(issueListScopePreferencePatch({ host: "github.com" }, null)).toBeNull(); + }); + it("marks only the opened issue as current", () => { const opened = { projectId: "project_1" as ProjectId, diff --git a/apps/web/src/routes/_chat.issues.tsx b/apps/web/src/routes/_chat.issues.tsx index cbdf131cd870..8860a9e9b0a7 100644 --- a/apps/web/src/routes/_chat.issues.tsx +++ b/apps/web/src/routes/_chat.issues.tsx @@ -41,6 +41,15 @@ import { } from "../components/issue/issueList.logic"; import { IssueCreateDialog } from "../components/issue/IssueCreateDialog"; import { IssueDetailPanel } from "../components/issue/IssueDetailPanel"; +import { + DEFAULT_ISSUE_PANEL_PREFERENCES, + issuePanelPreferencePatch, + issuePanelPreferencesKey, + IssuePanelPreferencesSchema, + normalizeIssueSearchQuery, + type IssuePanelPreferencePatch, + resolveIssuePanelPreferences, +} from "../components/issue/issuePanelPreferences"; import { LinearIcon } from "../components/Icons"; import { ListGhost } from "../components/sourceControl/ListGhosts"; import { @@ -75,6 +84,7 @@ import { Button } from "../components/ui/button"; import { MenuItem, MenuSeparator } from "../components/ui/menu"; import { SidebarInset } from "../components/ui/sidebar"; import { useLiveRefresh } from "../hooks/useLiveRefresh"; +import { useLocalStorage } from "../hooks/useLocalStorage"; import { usePrimarySettings } from "../hooks/useSettings"; import { selectActiveRightPanelSurface, @@ -101,8 +111,8 @@ import { toastManager } from "../components/ui/toast"; import { isWorkItemSelected, useWorkItemSelection } from "../workItemSelection"; export interface IssuesSearch { - readonly involvement: IssueInvolvement; - readonly state: IssueListState; + readonly involvement?: IssueInvolvement; + readonly state?: IssueListState; /** Scopes the list. Separate from the selection so one cannot silently change the other. */ readonly projectId?: ProjectId; /** @@ -125,6 +135,10 @@ export interface IssuesSearch { readonly order?: IssueListOrder; } +type IssuesSearchPatch = { + [Key in keyof IssuesSearch]?: IssuesSearch[Key] | undefined; +}; + export function issueSelectionSearchPatch(target: { readonly projectId: ProjectId; readonly repository: string; @@ -256,6 +270,7 @@ function issueListSort(value: unknown): IssueListSort | undefined { /** Long enough that a keystroke does not become a request, short enough to feel answered. */ const SEARCH_DEBOUNCE_MS = 250; + /** * One whole page from the host and no more: every provider asks for one row beyond the page as * its "is there more" probe, and GitHub serves a hundred per request — so asking for ninety-nine @@ -277,13 +292,15 @@ export const Route = createFileRoute("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/_chat/issues")({ validateSearch: (raw: Record): IssuesSearch => { const sort = issueListSort(raw.sort); return { - involvement: - raw.involvement === "assigned" || - raw.involvement === "authored" || - raw.involvement === "mentioned" - ? raw.involvement - : "all", - state: raw.state === "closed" || raw.state === "all" ? raw.state : "open", + ...(raw.involvement === "all" || + raw.involvement === "assigned" || + raw.involvement === "authored" || + raw.involvement === "mentioned" + ? { involvement: raw.involvement } + : {}), + ...(raw.state === "open" || raw.state === "closed" || raw.state === "all" + ? { state: raw.state } + : {}), ...(typeof raw.repository === "string" && raw.repository ? { repository: raw.repository.slice(0, 200) } : {}), @@ -303,12 +320,19 @@ export const Route = createFileRoute("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/_chat/issues")({ ...(typeof raw.label === "string" && raw.label ? { label: raw.label.slice(0, 200) } : {}), ...(sort === undefined ? {} : { sort }), ...(raw.order === "asc" || raw.order === "desc" ? { order: raw.order } : {}), - ...(typeof raw.q === "string" && raw.q ? { q: raw.q.slice(0, 200) } : {}), + ...(typeof raw.q === "string" && raw.q ? { q: raw.q } : {}), }; }, component: IssuesRouteView, }); +export function issueListScopePreferencePatch( + patch: IssuesSearchPatch, + preferencePatch: IssuePanelPreferencePatch | null = issuePanelPreferencePatch(patch), +): IssuePanelPreferencePatch | null { + return "projectId" in patch ? null : preferencePatch; +} + function IssuesRouteView() { const search = Route.useSearch(); const navigate = useNavigate({ from: Route.fullPath }); @@ -371,6 +395,15 @@ function IssuesRouteView() { () => resolveProjectScope(search.projectId, projects, projectsKnown), [projects, projectsKnown, search.projectId], ); + const canSavePreferences = environmentId !== null && scopedProjectId !== undefined; + const preferencesKey = canSavePreferences + ? issuePanelPreferencesKey(environmentId, scopedProjectId) + : "t3.issues.panel.preferences:unscoped"; + const [preferences, setPreferences] = useLocalStorage( + preferencesKey, + DEFAULT_ISSUE_PANEL_PREFERENCES, + IssuePanelPreferencesSchema, + ); const rightPanelRef = useMemo( () => (environmentId === null ? null : scopeThreadRef(environmentId, ISSUES_PANEL_ID)), [environmentId], @@ -409,8 +442,8 @@ function IssuesRouteView() { search: (previous: IssuesSearch): IssuesSearch => { const next = { ...previous, ...patch }; return { - involvement: next.involvement ?? previous.involvement, - state: next.state ?? previous.state, + ...(next.involvement === undefined ? {} : { involvement: next.involvement }), + ...(next.state === undefined ? {} : { state: next.state }), ...(next.repository ? { repository: next.repository } : {}), ...(next.number ? { number: next.number } : {}), ...(next.projectId ? { projectId: next.projectId } : {}), @@ -435,27 +468,36 @@ function IssuesRouteView() { selectedProjectId: undefined, selectedProvider: undefined, }; - const updateListScope = (patch: { - [Key in keyof IssuesSearch]?: IssuesSearch[Key] | undefined; - }) => { + const updateListScope = ( + patch: IssuesSearchPatch, + preferencePatch?: IssuePanelPreferencePatch | null, + ) => { if (rightPanelRef !== null) { // Hide the old selection while retaining peer issue tabs for parallel reading. useRightPanelStore.getState().close(rightPanelRef); } + savePreferences(issueListScopePreferencePatch(patch, preferencePatch)); updateSearch({ ...patch, ...clearedSelection }); }; // Searching asks the hosts, which takes a round trip, so the text is held for a moment before // it is sent. Until it lands, the rows already on screen are narrowed locally: the answer is // late but the page is not. - const typedQuery = (search.q ?? "").trim(); - const sentQuery = useDebouncedValue(typedQuery, SEARCH_DEBOUNCE_MS); - const querySettled = typedQuery === sentQuery; - const sort: IssueListSort = search.sort ?? (sentQuery ? "best-match" : "updated"); - const order: IssueListOrder = search.order ?? "desc"; + const searchQuery = normalizeIssueSearchQuery(search.q ?? ""); + const sentQuery = useDebouncedValue(searchQuery, SEARCH_DEBOUNCE_MS); + const querySettled = searchQuery === sentQuery; + const resolvedPreferences = resolveIssuePanelPreferences(preferences, search); + const { state, involvement, label } = resolvedPreferences; + const sort: IssueListSort = search.sort ?? (sentQuery ? "best-match" : preferences.sort); + const order: IssueListOrder = search.order ?? preferences.order; + + const savePreferences = (preferencePatch: IssuePanelPreferencePatch | null) => { + if (!canSavePreferences || preferencePatch === null) return; + setPreferences((current) => resolveIssuePanelPreferences(current, preferencePatch)); + }; // Page size is view state, not a URL concern: a shared link should open the first page. - const scopeKey = `${environmentId ?? ""}:${search.state}:${search.involvement}:${scopedProjectId ?? ""}:${search.host ?? ""}:${sort}:${order}`; + const scopeKey = `${environmentId ?? ""}:${state}:${involvement}:${scopedProjectId ?? ""}:${search.host ?? ""}:${sort}:${order}`; const filterKey = `${scopeKey}:${sentQuery}`; // Where the next slice carries on from, per repository, as the server handed it back. Sending // it is what makes a second page cost a second page rather than the whole list again — and a @@ -481,11 +523,11 @@ function IssuesRouteView() { : issueEnvironment.list({ environmentId: issueEnvironmentId, input: { - state: search.state, + state, // The hosts narrow by involvement themselves — GitHub by author and assignee, and so // on — so asking them is the difference between a page of results and a page of // everything with the answer somewhere further down it. - involvement: search.involvement, + involvement, limit: pageSize, sort, order, @@ -513,11 +555,11 @@ function IssuesRouteView() { : issueEnvironment.list({ environmentId: issueEnvironmentId, input: { - state: search.state, - involvement: search.involvement, + state, + involvement, limit: PAGE_SIZE, - sort: search.sort ?? "updated", - order: search.order ?? "desc", + sort: search.sort ?? preferences.sort, + order: search.order ?? preferences.order, ...(scopedProjectId ? { projectId: scopedProjectId } : {}), ...(search.host ? { host: search.host } : {}), }, @@ -530,21 +572,18 @@ function IssuesRouteView() { // are read for one. These are the same atoms the Authored and Assigned tabs ask for, so // switching to either is answered from cache. const partitionsWanted = - search.involvement === "all" && - typedQuery.length === 0 && - sort === "updated" && - order === "desc"; + involvement === "all" && searchQuery.length === 0 && sort === "updated" && order === "desc"; const authoredQuery = useEnvironmentQuery( issueEnvironmentId === null || !partitionsWanted ? null : issueEnvironment.list({ environmentId: issueEnvironmentId, input: { - state: search.state, + state, involvement: "authored", limit: PAGE_SIZE, - sort: search.sort ?? "updated", - order: search.order ?? "desc", + sort: search.sort ?? preferences.sort, + order: search.order ?? preferences.order, ...(scopedProjectId ? { projectId: scopedProjectId } : {}), ...(search.host ? { host: search.host } : {}), }, @@ -556,11 +595,11 @@ function IssuesRouteView() { : issueEnvironment.list({ environmentId: issueEnvironmentId, input: { - state: search.state, + state, involvement: "assigned", limit: PAGE_SIZE, - sort: search.sort ?? "updated", - order: search.order ?? "desc", + sort: search.sort ?? preferences.sort, + order: search.order ?? preferences.order, ...(scopedProjectId ? { projectId: scopedProjectId } : {}), ...(search.host ? { host: search.host } : {}), }, @@ -693,12 +732,12 @@ function IssuesRouteView() { return null; } const entries = narrowIssuesToFilters(loaded.data.entries, { - state: search.state, + state, projectId: scopedProjectId, host: search.host, }); return entries.length === 0 ? null : { ...loaded.data, entries }; - }, [environmentId, loaded, scopeKey, scopedProjectId, search.host, search.state]); + }, [environmentId, loaded, scopeKey, scopedProjectId, search.host, state]); // With nothing typed and nothing to carry on from, the answer is taken from the read that is // keyed to exactly that question. Otherwise a search's answer lingers for a render after the // text has gone — the data cannot say which question it belongs to, but the read it came from @@ -832,31 +871,31 @@ function IssuesRouteView() { const entries = useMemo(() => { const known = ordered?.key === filterKey ? ordered.entries : (listData?.entries ?? []); - const involvementEntries = filterIssuesByInvolvement(known, viewers, search.involvement); + const involvementEntries = filterIssuesByInvolvement(known, viewers, involvement); // The hosts search more than the row shows — a body, a comment — so once their answer is in, // narrowing it again here would throw away matches the reader asked for. The local pass // stands in for the answer that has not arrived yet, and for the hosts that answered without // searching at all: their rows arrive whole and would otherwise sit under a search that // never touched them. const labelled = - search.label === undefined + label === undefined ? involvementEntries : involvementEntries.filter((entry) => - entry.labels.some((entryLabel) => entryLabel.name === search.label), + entry.labels.some((entryLabel) => entryLabel.name === label), ); - if (typedQuery.length === 0) return labelled; + if (searchQuery.length === 0) return labelled; const answeredLocally = querySettled && !showingCarried; - return filterIssueQueryResults(labelled, typedQuery, answeredLocally, searchingHosts); + return filterIssueQueryResults(labelled, searchQuery, answeredLocally, searchingHosts); }, [ filterKey, listData, ordered, querySettled, - search.involvement, - search.label, + involvement, + label, searchingHosts, showingCarried, - typedQuery, + searchQuery, viewers, ]); @@ -867,9 +906,9 @@ function IssuesRouteView() { const labelOptions = useMemo(() => { const known = ordered?.key === filterKey ? ordered.entries : (listData?.entries ?? []); const names = new Set(known.flatMap((entry) => entry.labels.map((label) => label.name))); - if (search.label !== undefined) names.add(search.label); + if (label !== undefined) names.add(label); return [...names].sort((left, right) => left.localeCompare(right)); - }, [filterKey, listData, ordered, search.label]); + }, [filterKey, label, listData, ordered]); const sentinelRef = useRef(null); useEffect(() => { @@ -921,12 +960,7 @@ function IssuesRouteView() { ]); const groups = useMemo(() => { - if ( - search.involvement !== "all" || - sort !== "updated" || - order !== "desc" || - typedQuery.length > 0 - ) { + if (involvement !== "all" || sort !== "updated" || order !== "desc" || searchQuery.length > 0) { return [{ key: "others" as const, label: "", entries }]; } // Until both partitions have answered, the snapshot's stand in — they are yesterday's @@ -948,8 +982,7 @@ function IssuesRouteView() { authored, assigned, (entry) => - search.label === undefined || - entry.labels.some((entryLabel) => entryLabel.name === search.label), + label === undefined || entry.labels.some((entryLabel) => entryLabel.name === label), ); }, [ assignedQuery.data?.entries, @@ -960,10 +993,10 @@ function IssuesRouteView() { order, partitionsWanted, scopeKey, - search.involvement, - search.label, + involvement, + label, sort, - typedQuery.length, + searchQuery.length, viewers, ]); @@ -1125,7 +1158,7 @@ function IssuesRouteView() { 0 && (!querySettled || showingCarried)} + busy={searchQuery.length > 0 && (!querySettled || showingCarried)} onChange={(query) => updateSearch({ q: query || undefined })} /> ); @@ -1158,7 +1191,7 @@ function IssuesRouteView() { // so that case waits with the skeletons rather than answering for the hosts. A search says so // in its own words and is left to. const carriedToNothing = - showingCarried && listQuery.isPending && entries.length === 0 && typedQuery.length === 0; + showingCarried && listQuery.isPending && entries.length === 0 && searchQuery.length === 0; const listBody = ( <> {!capabilityKnown ? ( @@ -1179,14 +1212,14 @@ function IssuesRouteView() { hasProjects={!projectsKnown || projects.length > 0} refreshing={refreshing} onRefresh={() => void refreshFromHost()} - query={typedQuery} + query={searchQuery} filtered={ - search.state !== "open" || - search.involvement !== "all" || + state !== "open" || + involvement !== "all" || scopedProjectId !== undefined || search.host !== undefined } - searching={typedQuery.length > 0 && (!querySettled || showingCarried)} + searching={searchQuery.length > 0 && (!querySettled || showingCarried)} canLoadMore={listData?.truncated === true && (canContinue || pageSize < MAX_PAGE_SIZE)} loadingMore={loadingMore} onClearQuery={() => updateSearch({ q: undefined })} @@ -1289,19 +1322,25 @@ function IssuesRouteView() { sort={sort} order={order} onSort={(nextSort) => - updateListScope({ - sort: nextSort === "updated" && sentQuery.length === 0 ? undefined : nextSort, - }) + updateListScope( + { + sort: nextSort === "updated" && sentQuery.length === 0 ? undefined : nextSort, + }, + { sort: nextSort }, + ) } onOrder={(nextOrder) => - updateListScope({ order: nextOrder === "desc" ? undefined : nextOrder }) + updateListScope( + { order: nextOrder === "desc" ? undefined : nextOrder }, + { order: nextOrder }, + ) } /> updateListScope({ state })} - involvement={search.involvement} + involvement={involvement} involvementOptions={INVOLVEMENT_TABS} onInvolvement={(involvement) => updateListScope({ involvement })} projectFilter={{ @@ -1312,7 +1351,7 @@ function IssuesRouteView() { onProject: (projectId) => updateListScope({ projectId, sort: undefined, order: undefined }), }} - label={search.label} + label={label} labels={labelOptions} onLabel={(label) => updateListScope({ label })} /> @@ -1322,8 +1361,8 @@ function IssuesRouteView() { refreshing, onRefresh: () => void refreshFromHost(), searchValue: search.q ?? "", - involvement: search.involvement, - state: search.state, + involvement, + state, host: search.host, hostMenuOptions, hostMenuAction: { @@ -1333,7 +1372,7 @@ function IssuesRouteView() { onInvolvement: (involvement: IssueInvolvement) => updateListScope({ involvement }), onState: (state: IssueListState) => updateListScope({ state }), onHost: (host: string | undefined) => - updateListScope({ host, sort: undefined, order: undefined }), + updateListScope({ host, sort: undefined, order: undefined }, null), searchInput, filtersMenu, rightPanelControl: !issuesSupported || rightPanelState.isOpen ? null : panelToggleControls, @@ -1409,7 +1448,6 @@ function IssuesRouteView() { onAddDiff={() => undefined} onAddFiles={() => undefined} onAddPullRequest={() => undefined} - onAddIssue={() => undefined} onAddAgents={() => undefined} onAddDevice={() => undefined} browserAvailable={false} @@ -1417,8 +1455,6 @@ function IssuesRouteView() { diffAvailable={false} filesAvailable={false} pullRequestAvailable={false} - issueAvailable={false} - pullRequestsAvailable={false} agentsAvailable={false} deviceAvailable={false} liveAgentCount={0} diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index 43d467cd6548..e9585d86f69c 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -2126,7 +2126,6 @@ function PullRequestsRouteView() { onAddDiff={() => undefined} onAddFiles={() => undefined} onAddPullRequest={() => undefined} - onAddIssue={() => undefined} onAddAgents={() => undefined} onAddDevice={() => undefined} browserAvailable={false} @@ -2134,8 +2133,6 @@ function PullRequestsRouteView() { diffAvailable={false} filesAvailable={false} pullRequestAvailable={false} - issueAvailable={false} - pullRequestsAvailable={false} agentsAvailable={false} deviceAvailable={false} liveAgentCount={0} diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 0ae85575abf4..f0da934dde35 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -245,7 +245,9 @@ even when its changes look independent. Stack actions require an environment tha ### Browse Repository History -Open the right panel menu and choose **Repository** to explore history and linked pull requests without leaving your thread. Select **History** to browse a connected commit graph, branches, remotes, and tags. Select a ref to focus on it, or choose **All refs** to inspect the wider repository. +Open the right panel menu and choose **Repository** to explore history, issues, and linked pull requests without leaving your thread. Select **History** to browse a connected commit graph, branches, remotes, and tags. Select a ref to focus on it, or choose **All refs** to inspect the wider repository. Select **Issues** to search and filter your project's tracker, then open, update, or hand off an issue from the same thread. Select **Pull Requests** to review the pull requests linked to the thread. + +Your issue filters and sort choices are remembered separately for each project and connected environment. Search text is temporary. History is currently available in web and desktop project views. Mobile keeps its existing Git status and actions, but does not yet include this dense history workspace. @@ -253,7 +255,7 @@ In a narrow web window, use **Branches** and **Details** to open those panes ove - Search by commit subject, author, ref, or hash. Use the clear control or `Escape` to reset a search. - Select a commit to view its metadata and changed files, then open the same diff view used elsewhere in T3 Code. History diffs use Git's normal patch output, so they match the familiar command-line review view. -- Copy a full commit hash from a row. When a GitHub repository is connected, issue references in commit subjects open the matching issue. +- Copy a full commit hash from a row. - Branch rows show ahead and behind counts when an upstream is configured. For large repositories, history is loaded in pages and the commit list is virtualized. The initial browsing window is intentionally bounded so the right panel remains responsive; select a branch or search to narrow the result.