+
+
+
{data.title}
-
+
{data.state}
{data.author && (
<>
- ·
- by {data.author}
+ ·
+ by {data.author}
>
)}
@@ -155,7 +209,7 @@ function IssueDetailPage() {
{data.body.trim() ? (
) : (
-
+
No description provided.
)}
@@ -164,68 +218,3 @@ function IssueDetailPage() {
);
}
-
-interface HeaderProps {
- issueNumber: number;
- url: string | null;
- isClosed: boolean;
- onBack: () => void;
- onAddToWorkspace: (() => void) | null;
-}
-
-function Header({
- issueNumber,
- url,
- isClosed,
- onBack,
- onAddToWorkspace,
-}: HeaderProps) {
- const StateIcon = isClosed ? GoIssueClosed : GoIssueOpened;
- return (
-
-
-
-
-
-
- #{issueNumber}
-
- {/* Window-drag leaf standing in for the hidden TopBar. */}
-
-
- {url && (
-
-
-
- )}
- {onAddToWorkspace && (
-
-
- Add to workspace
-
- )}
-
-
- );
-}
diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/layout.tsx
index 00a4378e30b..e425cb6f63b 100644
--- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/layout.tsx
+++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/layout.tsx
@@ -7,6 +7,7 @@ export type TasksSearch = {
type?: "tasks" | "prs" | "issues";
project?: string;
linearProject?: string;
+ state?: "open" | "all";
};
export const Route = createFileRoute("/_authenticated/_dashboard/tasks")({
@@ -25,6 +26,9 @@ export const Route = createFileRoute("/_authenticated/_dashboard/tasks")({
typeof search.linearProject === "string"
? search.linearProject
: undefined,
+ state: ["open", "all"].includes(search.state as string)
+ ? (search.state as TasksSearch["state"])
+ : undefined,
}),
});
diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/page.tsx
index 610499afa83..dcef64ea43f 100644
--- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/page.tsx
+++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/page.tsx
@@ -1,4 +1,4 @@
-import { createFileRoute } from "@tanstack/react-router";
+import { createFileRoute, Navigate } from "@tanstack/react-router";
import { TasksView } from "./components/TasksView";
import { Route as TasksLayoutRoute } from "./layout";
@@ -7,8 +7,21 @@ export const Route = createFileRoute("/_authenticated/_dashboard/tasks/")({
});
function TasksPage() {
- const { tab, assignee, search, type, project, linearProject } =
+ const { tab, assignee, search, type, project, linearProject, state } =
TasksLayoutRoute.useSearch();
+ if (type === "prs") {
+ return (
+
+ );
+ }
return (
);
}
diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/pr/$prNumber/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/pr/$prNumber/page.tsx
index a83d2f7b1ca..7d05551a76d 100644
--- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/pr/$prNumber/page.tsx
+++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/pr/$prNumber/page.tsx
@@ -1,228 +1,22 @@
-import { Button } from "@superset/ui/button";
-import { ScrollArea } from "@superset/ui/scroll-area";
-import { useQuery } from "@tanstack/react-query";
-import { createFileRoute, useNavigate } from "@tanstack/react-router";
-import { useMemo } from "react";
-import { HiArrowLeft } from "react-icons/hi2";
-import { LuExternalLink, LuPlus } from "react-icons/lu";
-import { MarkdownRenderer } from "renderer/components/MarkdownRenderer";
-import { useHostUrl } from "renderer/hooks/host-service/useHostTargetUrl";
-import { getHostServiceClientByUrl } from "renderer/lib/host-service-client";
-import {
- normalizePRState,
- PRIcon,
- type PRState,
-} from "renderer/screens/main/components/PRIcon";
-import {
- type LinkedPR,
- useNewWorkspaceDraftStore,
-} from "renderer/stores/new-workspace-draft";
-import { useOpenNewWorkspaceModal } from "renderer/stores/new-workspace-modal";
+import { createFileRoute, Navigate } from "@tanstack/react-router";
import { Route as TasksLayoutRoute } from "../../layout";
export const Route = createFileRoute(
"/_authenticated/_dashboard/tasks/pr/$prNumber/",
)({
- component: PullRequestDetailPage,
+ component: LegacyPullRequestRedirect,
});
-function PullRequestDetailPage() {
- const { prNumber: prNumberRaw } = Route.useParams();
- const prNumber = Number.parseInt(prNumberRaw, 10);
- const search = TasksLayoutRoute.useSearch();
- const navigate = useNavigate();
- const hostUrl = useHostUrl(null);
- const projectId = search.project ?? null;
- const updateDraft = useNewWorkspaceDraftStore((s) => s.updateDraft);
- const resetDraft = useNewWorkspaceDraftStore((s) => s.resetDraft);
- const openModal = useOpenNewWorkspaceModal();
+function LegacyPullRequestRedirect() {
+ const { prNumber } = Route.useParams();
+ const { search, project, state } = TasksLayoutRoute.useSearch();
- const backSearch = useMemo(() => {
- const s: Record
= {};
- if (search.tab) s.tab = search.tab;
- if (search.assignee) s.assignee = search.assignee;
- if (search.search) s.search = search.search;
- if (search.type) s.type = search.type;
- if (search.project) s.project = search.project;
- return s;
- }, [search]);
-
- const { data, isLoading, error } = useQuery({
- queryKey: ["pull-request-detail", projectId, hostUrl, prNumber],
- queryFn: async () => {
- if (!hostUrl || !projectId) return null;
- const client = getHostServiceClientByUrl(hostUrl);
- return client.pullRequests.getContent.query({
- projectId,
- prNumber,
- });
- },
- enabled: !!hostUrl && !!projectId && Number.isFinite(prNumber),
- retry: false,
- staleTime: 30_000,
- gcTime: 10 * 60_000,
- });
-
- const handleBack = () => {
- navigate({ to: "/tasks", search: backSearch });
- };
-
- const handleAddToWorkspace = () => {
- if (!projectId || !data) return;
- const linkedPR: LinkedPR = {
- prNumber: data.number,
- title: data.title,
- url: data.url,
- state: normalizePRState(data.state, data.isDraft),
- };
- resetDraft();
- updateDraft({ selectedProjectId: projectId, linkedPR });
- openModal(projectId);
- };
-
- if (!projectId) {
- return (
-
- No project specified.
-
- );
- }
-
- if (isLoading) {
- return (
-
- Loading pull request…
-
- );
- }
-
- if (error instanceof Error || !data) {
- return (
-
-
-
- {error instanceof Error ? error.message : "Pull request not found."}
-
-
- );
- }
-
- const state = normalizePRState(data.state, data.isDraft);
- const stateLabel = data.isDraft ? "Draft" : data.state;
- const branchSummary = data.branch
- ? `${data.headRepositoryOwner && data.isCrossRepository ? `${data.headRepositoryOwner}:${data.branch}` : data.branch} → ${data.baseBranch}`
- : null;
-
- return (
-
-
-
-
-
-
-
-
- {stateLabel}
- {data.author && (
- <>
- ·
- by {data.author}
- >
- )}
- {branchSummary && (
- <>
- ·
- {branchSummary}
- >
- )}
-
-
- {data.body.trim() ? (
-
- ) : (
-
- No description provided.
-
- )}
-
-
-
- );
-}
-
-interface HeaderProps {
- prNumber: number;
- url: string | null;
- state: PRState;
- onBack: () => void;
- onAddToWorkspace: (() => void) | null;
-}
-
-function Header({
- prNumber,
- url,
- state,
- onBack,
- onAddToWorkspace,
-}: HeaderProps) {
return (
-
-
-
-
-
-
- #{prNumber}
-
- {/* Window-drag leaf standing in for the hidden TopBar. */}
-
-
- {url && (
-
-
-
- )}
- {onAddToWorkspace && (
-
-
- Add to workspace
-
- )}
-
-
+
);
}
diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/stores/tasks-filter-state.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/stores/tasks-filter-state.test.ts
new file mode 100644
index 00000000000..d9e06ddbe7c
--- /dev/null
+++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/stores/tasks-filter-state.test.ts
@@ -0,0 +1,81 @@
+import { describe, expect, test } from "bun:test";
+import {
+ migrateTasksFilterState,
+ tasksSearchFromFilters,
+} from "./tasks-filter-state";
+
+describe("tasksSearchFromFilters", () => {
+ test("keeps default task filters out of the URL", () => {
+ expect(
+ tasksSearchFromFilters({
+ tab: "all",
+ assignee: null,
+ search: "",
+ typeTab: "tasks",
+ projectFilter: null,
+ linearProjectFilter: null,
+ includeClosedIssues: false,
+ }),
+ ).toEqual({});
+ });
+
+ test("preserves GitHub Issue context across detail navigation", () => {
+ expect(
+ tasksSearchFromFilters({
+ tab: "active",
+ assignee: "me",
+ search: "remote host",
+ typeTab: "issues",
+ projectFilter: "project-1",
+ linearProjectFilter: null,
+ includeClosedIssues: true,
+ }),
+ ).toEqual({
+ tab: "active",
+ assignee: "me",
+ search: "remote host",
+ type: "issues",
+ project: "project-1",
+ state: "all",
+ });
+ });
+
+ test("does not leak the issue state filter into Linear tasks", () => {
+ expect(
+ tasksSearchFromFilters({
+ tab: "all",
+ assignee: null,
+ search: "",
+ typeTab: "tasks",
+ projectFilter: "project-1",
+ linearProjectFilter: "linear-project-1",
+ includeClosedIssues: true,
+ }),
+ ).toEqual({
+ project: "project-1",
+ linearProject: "linear-project-1",
+ });
+ });
+});
+
+describe("migrateTasksFilterState", () => {
+ test("moves legacy PR tabs back to Tasks and defaults issue state safely", () => {
+ expect(
+ migrateTasksFilterState({
+ typeTab: "prs",
+ projectFilter: "project-1",
+ }),
+ ).toMatchObject({
+ typeTab: "tasks",
+ projectFilter: "project-1",
+ includeClosedIssues: false,
+ });
+ });
+
+ test("survives corrupt persisted values", () => {
+ expect(migrateTasksFilterState(null)).toMatchObject({
+ typeTab: "tasks",
+ includeClosedIssues: false,
+ });
+ });
+});
diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/stores/tasks-filter-state.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/stores/tasks-filter-state.ts
index 727f7eb2bca..3e87d838bdd 100644
--- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/stores/tasks-filter-state.ts
+++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/stores/tasks-filter-state.ts
@@ -2,7 +2,7 @@ import { create } from "zustand";
import { persist } from "zustand/middleware";
export type ViewMode = "table" | "board";
-export type TypeTab = "tasks" | "prs" | "issues";
+export type TypeTab = "tasks" | "issues";
export type FilterTab = "all" | "active" | "backlog";
interface TasksFilterState {
@@ -13,6 +13,7 @@ interface TasksFilterState {
typeTab: TypeTab;
projectFilter: string | null;
linearProjectFilter: string | null;
+ includeClosedIssues: boolean;
setTab: (tab: FilterTab) => void;
setAssignee: (assignee: string | null) => void;
setSearch: (search: string) => void;
@@ -20,6 +21,21 @@ interface TasksFilterState {
setTypeTab: (typeTab: TypeTab) => void;
setProjectFilter: (projectFilter: string | null) => void;
setLinearProjectFilter: (linearProjectFilter: string | null) => void;
+ setIncludeClosedIssues: (includeClosedIssues: boolean) => void;
+}
+
+export function migrateTasksFilterState(
+ persistedState: unknown,
+): TasksFilterState {
+ const state =
+ persistedState && typeof persistedState === "object"
+ ? (persistedState as Record)
+ : {};
+ return {
+ ...state,
+ typeTab: state.typeTab === "issues" ? "issues" : "tasks",
+ includeClosedIssues: state.includeClosedIssues === true,
+ } as unknown as TasksFilterState;
}
export const useTasksFilterStore = create()(
@@ -32,6 +48,7 @@ export const useTasksFilterStore = create()(
typeTab: "tasks",
projectFilter: null,
linearProjectFilter: null,
+ includeClosedIssues: false,
setTab: (tab) => set({ tab }),
setAssignee: (assignee) => set({ assignee }),
setSearch: (search) => set({ search }),
@@ -40,16 +57,20 @@ export const useTasksFilterStore = create()(
setProjectFilter: (projectFilter) => set({ projectFilter }),
setLinearProjectFilter: (linearProjectFilter) =>
set({ linearProjectFilter }),
+ setIncludeClosedIssues: (includeClosedIssues) =>
+ set({ includeClosedIssues }),
}),
{
name: "tasks-filter-state",
- version: 1,
+ version: 3,
+ migrate: migrateTasksFilterState,
partialize: (state) => ({
projectFilter: state.projectFilter,
linearProjectFilter: state.linearProjectFilter,
tab: state.tab,
typeTab: state.typeTab,
viewMode: state.viewMode,
+ includeClosedIssues: state.includeClosedIssues,
}),
},
),
@@ -62,6 +83,7 @@ export interface TasksFilters {
typeTab: TypeTab;
projectFilter: string | null;
linearProjectFilter: string | null;
+ includeClosedIssues: boolean;
}
export function tasksSearchFromFilters(
@@ -75,5 +97,7 @@ export function tasksSearchFromFilters(
if (filters.projectFilter) out.project = filters.projectFilter;
if (filters.linearProjectFilter)
out.linearProject = filters.linearProjectFilter;
+ if (filters.typeTab === "issues" && filters.includeClosedIssues)
+ out.state = "all";
return out;
}
diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/parsePositiveIntegerParam/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/parsePositiveIntegerParam/index.ts
new file mode 100644
index 00000000000..eef8ecb7a96
--- /dev/null
+++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/parsePositiveIntegerParam/index.ts
@@ -0,0 +1 @@
+export { parsePositiveIntegerParam } from "./parsePositiveIntegerParam";
diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/parsePositiveIntegerParam/parsePositiveIntegerParam.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/parsePositiveIntegerParam/parsePositiveIntegerParam.test.ts
new file mode 100644
index 00000000000..cd6d8ff0755
--- /dev/null
+++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/parsePositiveIntegerParam/parsePositiveIntegerParam.test.ts
@@ -0,0 +1,17 @@
+import { describe, expect, test } from "bun:test";
+import { parsePositiveIntegerParam } from "./parsePositiveIntegerParam";
+
+describe("parsePositiveIntegerParam", () => {
+ test("accepts positive safe integers", () => {
+ expect(parsePositiveIntegerParam("5975")).toBe(5975);
+ });
+
+ test("rejects partial, non-positive, and unsafe values", () => {
+ expect(parsePositiveIntegerParam("12abc")).toBeNull();
+ expect(parsePositiveIntegerParam("0")).toBeNull();
+ expect(parsePositiveIntegerParam("-1")).toBeNull();
+ expect(
+ parsePositiveIntegerParam(String(Number.MAX_SAFE_INTEGER + 1)),
+ ).toBeNull();
+ });
+});
diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/parsePositiveIntegerParam/parsePositiveIntegerParam.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/parsePositiveIntegerParam/parsePositiveIntegerParam.ts
new file mode 100644
index 00000000000..429e2c2bf3d
--- /dev/null
+++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/parsePositiveIntegerParam/parsePositiveIntegerParam.ts
@@ -0,0 +1,5 @@
+export function parsePositiveIntegerParam(value: string): number | null {
+ if (!/^[1-9]\d*$/.test(value)) return null;
+ const parsed = Number(value);
+ return Number.isSafeInteger(parsed) ? parsed : null;
+}
diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/shouldKeepWorkItemsPlaceholder/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/shouldKeepWorkItemsPlaceholder/index.ts
new file mode 100644
index 00000000000..a18ac111f21
--- /dev/null
+++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/shouldKeepWorkItemsPlaceholder/index.ts
@@ -0,0 +1 @@
+export { shouldKeepWorkItemsPlaceholder } from "./shouldKeepWorkItemsPlaceholder";
diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/shouldKeepWorkItemsPlaceholder/shouldKeepWorkItemsPlaceholder.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/shouldKeepWorkItemsPlaceholder/shouldKeepWorkItemsPlaceholder.test.ts
new file mode 100644
index 00000000000..01119bc0ccb
--- /dev/null
+++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/shouldKeepWorkItemsPlaceholder/shouldKeepWorkItemsPlaceholder.test.ts
@@ -0,0 +1,34 @@
+import { describe, expect, test } from "bun:test";
+import { shouldKeepWorkItemsPlaceholder } from "./shouldKeepWorkItemsPlaceholder";
+
+describe("shouldKeepWorkItemsPlaceholder", () => {
+ test("keeps rows while filtering within one project and host", () => {
+ expect(
+ shouldKeepWorkItemsPlaceholder(
+ ["pullRequests", "searchPullRequests", "project-1", "host-a"],
+ "project-1",
+ "host-a",
+ ),
+ ).toBe(true);
+ });
+
+ test("drops stale rows when the project changes", () => {
+ expect(
+ shouldKeepWorkItemsPlaceholder(
+ ["pullRequests", "searchPullRequests", "project-1", "host-a"],
+ "project-2",
+ "host-a",
+ ),
+ ).toBe(false);
+ });
+
+ test("drops stale rows when routing moves to another host", () => {
+ expect(
+ shouldKeepWorkItemsPlaceholder(
+ ["pullRequests", "searchPullRequests", "project-1", "host-a"],
+ "project-1",
+ "host-b",
+ ),
+ ).toBe(false);
+ });
+});
diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/shouldKeepWorkItemsPlaceholder/shouldKeepWorkItemsPlaceholder.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/shouldKeepWorkItemsPlaceholder/shouldKeepWorkItemsPlaceholder.ts
new file mode 100644
index 00000000000..78a2b489cf0
--- /dev/null
+++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/shouldKeepWorkItemsPlaceholder/shouldKeepWorkItemsPlaceholder.ts
@@ -0,0 +1,9 @@
+export function shouldKeepWorkItemsPlaceholder(
+ previousQueryKey: readonly unknown[] | undefined,
+ projectId: string | null,
+ hostUrl: string | null,
+): boolean {
+ return (
+ previousQueryKey?.[2] === projectId && previousQueryKey?.[3] === hostUrl
+ );
+}
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarHeader/WorkspaceSidebarHeader.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarHeader/WorkspaceSidebarHeader.tsx
index df8b478b95c..3cad40f8546 100644
--- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarHeader/WorkspaceSidebarHeader.tsx
+++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarHeader/WorkspaceSidebarHeader.tsx
@@ -1,9 +1,14 @@
import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip";
import { cn } from "@superset/ui/utils";
import { useMatchRoute, useNavigate } from "@tanstack/react-router";
+import { GoGitPullRequest } from "react-icons/go";
import { HiOutlineClipboardDocumentList } from "react-icons/hi2";
import { LuLayers } from "react-icons/lu";
import { GATED_FEATURES, usePaywall } from "renderer/components/Paywall";
+import {
+ pullRequestsSearchFromFilters,
+ usePullRequestsFilterStore,
+} from "renderer/routes/_authenticated/_dashboard/pull-requests/stores/pullRequestsFilterStore";
import {
tasksSearchFromFilters,
useTasksFilterStore,
@@ -24,6 +29,10 @@ export function WorkspaceSidebarHeader({
const isWorkspacesListOpen = !!matchRoute({ to: "/workspaces" });
const isTasksOpen = !!matchRoute({ to: "/tasks", fuzzy: true });
+ const isPullRequestsOpen = !!matchRoute({
+ to: "/pull-requests",
+ fuzzy: true,
+ });
const handleWorkspacesClick = () => {
if (isWorkspacesListOpen) {
@@ -41,7 +50,13 @@ export function WorkspaceSidebarHeader({
typeTab: lastTypeTab,
projectFilter: lastProjectFilter,
linearProjectFilter: lastLinearProjectFilter,
+ includeClosedIssues: lastIncludeClosedIssues,
} = useTasksFilterStore();
+ const {
+ search: lastPullRequestsSearch,
+ projectFilter: lastPullRequestsProjectFilter,
+ includeClosed: lastPullRequestsIncludeClosed,
+ } = usePullRequestsFilterStore();
const handleTasksClick = () => {
gateFeature(GATED_FEATURES.TASKS, () => {
@@ -54,6 +69,20 @@ export function WorkspaceSidebarHeader({
typeTab: lastTypeTab,
projectFilter: lastProjectFilter,
linearProjectFilter: lastLinearProjectFilter,
+ includeClosedIssues: lastIncludeClosedIssues,
+ }),
+ });
+ });
+ };
+
+ const handlePullRequestsClick = () => {
+ gateFeature(GATED_FEATURES.TASKS, () => {
+ navigate({
+ to: "/pull-requests",
+ search: pullRequestsSearchFromFilters({
+ search: lastPullRequestsSearch,
+ projectFilter: lastPullRequestsProjectFilter,
+ includeClosed: lastPullRequestsIncludeClosed,
}),
});
});
@@ -85,6 +114,8 @@ export function WorkspaceSidebarHeader({
- Tasks & PRs
+ Tasks
+
+
+
+
+
+
+
+
+ Pull requests
@@ -127,6 +178,8 @@ export function WorkspaceSidebarHeader({
+