@@ -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.