Merge public-gh/master into paperclip-subissues
This commit is contained in:
@@ -144,4 +144,12 @@ export const agentsApi = {
|
||||
) => api.post<HeartbeatRun | { status: "skipped" }>(agentPath(id, companyId, "/wakeup"), data),
|
||||
loginWithClaude: (id: string, companyId?: string) =>
|
||||
api.post<ClaudeLoginResult>(agentPath(id, companyId, "/claude-login"), {}),
|
||||
availableSkills: () =>
|
||||
api.get<{ skills: AvailableSkill[] }>("/skills/available"),
|
||||
};
|
||||
|
||||
export interface AvailableSkill {
|
||||
name: string;
|
||||
description: string;
|
||||
isPaperclipManaged: boolean;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { useState, useEffect, useRef, useMemo, useCallback } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AGENT_ADAPTER_TYPES } from "@paperclipai/shared";
|
||||
import {
|
||||
hasSessionCompactionThresholds,
|
||||
resolveSessionCompactionPolicy,
|
||||
type ResolvedSessionCompactionPolicy,
|
||||
} from "@paperclipai/adapter-utils";
|
||||
import type {
|
||||
Agent,
|
||||
AdapterEnvironmentTestResult,
|
||||
@@ -383,6 +388,31 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
const codexSearchEnabled = adapterType === "codex_local"
|
||||
? (isCreate ? Boolean(val!.search) : eff("adapterConfig", "search", Boolean(config.search)))
|
||||
: false;
|
||||
const effectiveRuntimeConfig = useMemo(() => {
|
||||
if (isCreate) {
|
||||
return {
|
||||
heartbeat: {
|
||||
enabled: val!.heartbeatEnabled,
|
||||
intervalSec: val!.intervalSec,
|
||||
},
|
||||
};
|
||||
}
|
||||
const mergedHeartbeat = {
|
||||
...(runtimeConfig.heartbeat && typeof runtimeConfig.heartbeat === "object"
|
||||
? runtimeConfig.heartbeat as Record<string, unknown>
|
||||
: {}),
|
||||
...overlay.heartbeat,
|
||||
};
|
||||
return {
|
||||
...runtimeConfig,
|
||||
heartbeat: mergedHeartbeat,
|
||||
};
|
||||
}, [isCreate, overlay.heartbeat, runtimeConfig, val]);
|
||||
const sessionCompaction = useMemo(
|
||||
() => resolveSessionCompactionPolicy(adapterType, effectiveRuntimeConfig),
|
||||
[adapterType, effectiveRuntimeConfig],
|
||||
);
|
||||
const showSessionCompactionCard = Boolean(sessionCompaction.adapterSessionManagement);
|
||||
|
||||
return (
|
||||
<div className={cn("relative", cards && "space-y-6")}>
|
||||
@@ -813,6 +843,12 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
numberHint={help.intervalSec}
|
||||
showNumber={val!.heartbeatEnabled}
|
||||
/>
|
||||
{showSessionCompactionCard && (
|
||||
<SessionCompactionPolicyCard
|
||||
adapterType={adapterType}
|
||||
resolution={sessionCompaction}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -835,6 +871,12 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
numberHint={help.intervalSec}
|
||||
showNumber={eff("heartbeat", "enabled", heartbeat.enabled !== false)}
|
||||
/>
|
||||
{showSessionCompactionCard && (
|
||||
<SessionCompactionPolicyCard
|
||||
adapterType={adapterType}
|
||||
resolution={sessionCompaction}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<CollapsibleSection
|
||||
title="Advanced Run Policy"
|
||||
@@ -922,6 +964,69 @@ function AdapterEnvironmentResult({ result }: { result: AdapterEnvironmentTestRe
|
||||
);
|
||||
}
|
||||
|
||||
function formatSessionThreshold(value: number, suffix: string) {
|
||||
if (value <= 0) return "Off";
|
||||
return `${value.toLocaleString("en-US")} ${suffix}`;
|
||||
}
|
||||
|
||||
function SessionCompactionPolicyCard({
|
||||
adapterType,
|
||||
resolution,
|
||||
}: {
|
||||
adapterType: string;
|
||||
resolution: ResolvedSessionCompactionPolicy;
|
||||
}) {
|
||||
const { adapterSessionManagement, policy, source } = resolution;
|
||||
if (!adapterSessionManagement) return null;
|
||||
|
||||
const adapterLabel = adapterLabels[adapterType] ?? adapterType;
|
||||
const sourceLabel = source === "agent_override" ? "Agent override" : "Adapter default";
|
||||
const rotationDisabled = !policy.enabled || !hasSessionCompactionThresholds(policy);
|
||||
const nativeSummary =
|
||||
adapterSessionManagement.nativeContextManagement === "confirmed"
|
||||
? `${adapterLabel} is treated as natively managing long context, so Paperclip fresh-session rotation defaults to off.`
|
||||
: adapterSessionManagement.nativeContextManagement === "likely"
|
||||
? `${adapterLabel} likely manages long context itself, but Paperclip still keeps conservative rotation defaults for now.`
|
||||
: `${adapterLabel} does not have verified native compaction behavior, so Paperclip keeps conservative rotation defaults.`;
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-sky-500/25 bg-sky-500/10 px-3 py-3 space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-xs font-medium text-sky-50">Session compaction</div>
|
||||
<span className="rounded-full border border-sky-400/30 px-2 py-0.5 text-[11px] text-sky-100">
|
||||
{sourceLabel}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-sky-100/90">
|
||||
{nativeSummary}
|
||||
</p>
|
||||
<p className="text-xs text-sky-100/80">
|
||||
{rotationDisabled
|
||||
? "No Paperclip-managed fresh-session thresholds are active for this adapter."
|
||||
: "Paperclip will start a fresh session when one of these thresholds is reached."}
|
||||
</p>
|
||||
<div className="grid grid-cols-3 gap-2 text-[11px] text-sky-100/85 tabular-nums">
|
||||
<div>
|
||||
<div className="text-sky-100/60">Runs</div>
|
||||
<div>{formatSessionThreshold(policy.maxSessionRuns, "runs")}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sky-100/60">Raw input</div>
|
||||
<div>{formatSessionThreshold(policy.maxRawInputTokens, "tokens")}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sky-100/60">Age</div>
|
||||
<div>{formatSessionThreshold(policy.maxSessionAgeHours, "hours")}</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[11px] text-sky-100/75">
|
||||
A large cumulative raw token total does not mean the full session is resent on every heartbeat.
|
||||
{source === "agent_override" && " This agent has an explicit runtimeConfig session compaction override."}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---- Internal sub-components ---- */
|
||||
|
||||
const ENABLED_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "cursor"]);
|
||||
|
||||
@@ -61,6 +61,10 @@ export interface MarkdownEditorRef {
|
||||
focus: () => void;
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
/* ---- Mention detection helpers ---- */
|
||||
|
||||
interface MentionState {
|
||||
@@ -251,6 +255,24 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||
try {
|
||||
const src = await handler(file);
|
||||
setUploadError(null);
|
||||
// After MDXEditor inserts the image, ensure two newlines follow it
|
||||
// so the cursor isn't stuck right next to the image.
|
||||
setTimeout(() => {
|
||||
const current = latestValueRef.current;
|
||||
const escapedSrc = escapeRegExp(src);
|
||||
const updated = current.replace(
|
||||
new RegExp(`(!\\[[^\\]]*\\]\\(${escapedSrc}\\))(?!\\n\\n)`, "g"),
|
||||
"$1\n\n",
|
||||
);
|
||||
if (updated !== current) {
|
||||
latestValueRef.current = updated;
|
||||
ref.current?.setMarkdown(updated);
|
||||
onChange(updated);
|
||||
requestAnimationFrame(() => {
|
||||
ref.current?.focus(undefined, { defaultSelection: "rootEnd" });
|
||||
});
|
||||
}
|
||||
}, 100);
|
||||
return src;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Image upload failed";
|
||||
|
||||
@@ -150,6 +150,71 @@ function ProjectStatusPicker({ status, onChange }: { status: string; onChange: (
|
||||
);
|
||||
}
|
||||
|
||||
function ArchiveDangerZone({
|
||||
project,
|
||||
onArchive,
|
||||
archivePending,
|
||||
}: {
|
||||
project: Project;
|
||||
onArchive: (archived: boolean) => void;
|
||||
archivePending?: boolean;
|
||||
}) {
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const isArchive = !project.archivedAt;
|
||||
const action = isArchive ? "Archive" : "Unarchive";
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-md border border-destructive/40 bg-destructive/5 px-4 py-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isArchive
|
||||
? "Archive this project to hide it from the sidebar and project selectors."
|
||||
: "Unarchive this project to restore it in the sidebar and project selectors."}
|
||||
</p>
|
||||
{archivePending ? (
|
||||
<Button size="sm" variant="destructive" disabled>
|
||||
<Loader2 className="h-3 w-3 animate-spin mr-1" />
|
||||
{isArchive ? "Archiving..." : "Unarchiving..."}
|
||||
</Button>
|
||||
) : confirming ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-destructive font-medium">
|
||||
{action} “{project.name}”?
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
setConfirming(false);
|
||||
onArchive(isArchive);
|
||||
}}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setConfirming(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => setConfirming(true)}
|
||||
>
|
||||
{isArchive ? (
|
||||
<><Archive className="h-3 w-3 mr-1" />{action} project</>
|
||||
) : (
|
||||
<><ArchiveRestore className="h-3 w-3 mr-1" />{action} project</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSaveState, onArchive, archivePending }: ProjectPropertiesProps) {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -1046,34 +1111,11 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
|
||||
<div className="text-xs font-medium text-destructive uppercase tracking-wide">
|
||||
Danger Zone
|
||||
</div>
|
||||
<div className="space-y-3 rounded-md border border-destructive/40 bg-destructive/5 px-4 py-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{project.archivedAt
|
||||
? "Unarchive this project to restore it in the sidebar and project selectors."
|
||||
: "Archive this project to hide it from the sidebar and project selectors."}
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={archivePending}
|
||||
onClick={() => {
|
||||
const action = project.archivedAt ? "Unarchive" : "Archive";
|
||||
const confirmed = window.confirm(
|
||||
`${action} project "${project.name}"?`,
|
||||
);
|
||||
if (!confirmed) return;
|
||||
onArchive(!project.archivedAt);
|
||||
}}
|
||||
>
|
||||
{archivePending ? (
|
||||
<><Loader2 className="h-3 w-3 animate-spin mr-1" />{project.archivedAt ? "Unarchiving..." : "Archiving..."}</>
|
||||
) : project.archivedAt ? (
|
||||
<><ArchiveRestore className="h-3 w-3 mr-1" />Unarchive project</>
|
||||
) : (
|
||||
<><Archive className="h-3 w-3 mr-1" />Archive project</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<ArchiveDangerZone
|
||||
project={project}
|
||||
onArchive={onArchive}
|
||||
archivePending={archivePending}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -377,21 +377,21 @@
|
||||
}
|
||||
|
||||
.paperclip-mdxeditor-content h1 {
|
||||
margin: 0 0 0.9em;
|
||||
margin: 1.4em 0 0.9em;
|
||||
font-size: 1.75em;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.paperclip-mdxeditor-content h2 {
|
||||
margin: 0 0 0.85em;
|
||||
margin: 1.3em 0 0.85em;
|
||||
font-size: 1.35em;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.paperclip-mdxeditor-content h3 {
|
||||
margin: 0 0 0.8em;
|
||||
margin: 1.2em 0 0.8em;
|
||||
font-size: 1.15em;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
@@ -585,8 +585,11 @@
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.paperclip-markdown :where(h1, h2, h3, h4) {
|
||||
margin-top: 1.15rem;
|
||||
.paperclip-markdown h1,
|
||||
.paperclip-markdown h2,
|
||||
.paperclip-markdown h3,
|
||||
.paperclip-markdown h4 {
|
||||
margin-top: 1.75rem;
|
||||
margin-bottom: 0.45rem;
|
||||
color: var(--foreground);
|
||||
font-weight: 600;
|
||||
|
||||
@@ -104,6 +104,9 @@ export const queryKeys = {
|
||||
liveRuns: (companyId: string) => ["live-runs", companyId] as const,
|
||||
runIssues: (runId: string) => ["run-issues", runId] as const,
|
||||
org: (companyId: string) => ["org", companyId] as const,
|
||||
skills: {
|
||||
available: ["skills", "available"] as const,
|
||||
},
|
||||
plugins: {
|
||||
all: ["plugins"] as const,
|
||||
examples: ["plugins", "examples"] as const,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
|
||||
import { useParams, useNavigate, Link, Navigate, useBeforeUnload } from "@/lib/router";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { agentsApi, type AgentKey, type ClaudeLoginResult } from "../api/agents";
|
||||
import { agentsApi, type AgentKey, type ClaudeLoginResult, type AvailableSkill } from "../api/agents";
|
||||
import { budgetsApi } from "../api/budgets";
|
||||
import { heartbeatsApi } from "../api/heartbeats";
|
||||
import { ApiError } from "../api/client";
|
||||
@@ -30,6 +30,7 @@ import { ScrollToBottom } from "../components/ScrollToBottom";
|
||||
import { formatCents, formatDate, relativeTime, formatTokens, visibleRunCostUsd } from "../lib/utils";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tabs } from "@/components/ui/tabs";
|
||||
import {
|
||||
Popover,
|
||||
@@ -186,11 +187,12 @@ function scrollToContainerBottom(container: ScrollContainer, behavior: ScrollBeh
|
||||
container.scrollTo({ top: container.scrollHeight, behavior });
|
||||
}
|
||||
|
||||
type AgentDetailView = "dashboard" | "configuration" | "runs" | "budget";
|
||||
type AgentDetailView = "dashboard" | "configuration" | "skills" | "runs" | "budget";
|
||||
|
||||
function parseAgentDetailView(value: string | null): AgentDetailView {
|
||||
if (value === "configure" || value === "configuration") return "configuration";
|
||||
if (value === "budget") return "budget";
|
||||
if (value === "skills") return value;
|
||||
if (value === "budget") return value;
|
||||
if (value === "runs") return value;
|
||||
return "dashboard";
|
||||
}
|
||||
@@ -578,10 +580,12 @@ export function AgentDetail() {
|
||||
const canonicalTab =
|
||||
activeView === "configuration"
|
||||
? "configuration"
|
||||
: activeView === "runs"
|
||||
? "runs"
|
||||
: activeView === "budget"
|
||||
? "budget"
|
||||
: activeView === "skills"
|
||||
? "skills"
|
||||
: activeView === "runs"
|
||||
? "runs"
|
||||
: activeView === "budget"
|
||||
? "budget"
|
||||
: "dashboard";
|
||||
if (routeAgentRef !== canonicalAgentRef || urlTab !== canonicalTab) {
|
||||
navigate(`/agents/${canonicalAgentRef}/${canonicalTab}`, { replace: true });
|
||||
@@ -697,6 +701,8 @@ export function AgentDetail() {
|
||||
crumbs.push({ label: `Run ${urlRunId.slice(0, 8)}` });
|
||||
} else if (activeView === "configuration") {
|
||||
crumbs.push({ label: "Configuration" });
|
||||
} else if (activeView === "skills") {
|
||||
crumbs.push({ label: "Skills" });
|
||||
} else if (activeView === "runs") {
|
||||
crumbs.push({ label: "Runs" });
|
||||
} else if (activeView === "budget") {
|
||||
@@ -856,6 +862,7 @@ export function AgentDetail() {
|
||||
items={[
|
||||
{ value: "dashboard", label: "Dashboard" },
|
||||
{ value: "configuration", label: "Configuration" },
|
||||
{ value: "skills", label: "Skills" },
|
||||
{ value: "runs", label: "Runs" },
|
||||
{ value: "budget", label: "Budget" },
|
||||
]}
|
||||
@@ -873,14 +880,9 @@ export function AgentDetail() {
|
||||
)}
|
||||
|
||||
{/* Floating Save/Cancel (desktop) */}
|
||||
{!isMobile && (
|
||||
{!isMobile && showConfigActionBar && (
|
||||
<div
|
||||
className={cn(
|
||||
"sticky top-6 z-10 float-right transition-opacity duration-150",
|
||||
showConfigActionBar
|
||||
? "opacity-100"
|
||||
: "opacity-0 pointer-events-none"
|
||||
)}
|
||||
className="sticky top-6 z-10 float-right transition-opacity duration-150"
|
||||
>
|
||||
<div className="flex items-center gap-2 bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-1.5 shadow-lg">
|
||||
<Button
|
||||
@@ -953,6 +955,12 @@ export function AgentDetail() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeView === "skills" && (
|
||||
<SkillsTab
|
||||
agent={agent}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeView === "runs" && (
|
||||
<RunsTab
|
||||
runs={heartbeats ?? []}
|
||||
@@ -1419,6 +1427,78 @@ function ConfigurationTab({
|
||||
);
|
||||
}
|
||||
|
||||
function SkillsTab({ agent }: { agent: Agent }) {
|
||||
const instructionsPath =
|
||||
typeof agent.adapterConfig?.instructionsFilePath === "string" && agent.adapterConfig.instructionsFilePath.trim().length > 0
|
||||
? agent.adapterConfig.instructionsFilePath
|
||||
: null;
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: queryKeys.skills.available,
|
||||
queryFn: () => agentsApi.availableSkills(),
|
||||
});
|
||||
const skills = data?.skills ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="border border-border rounded-lg p-4 space-y-2">
|
||||
<h3 className="text-sm font-medium">Skills</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Skills are reusable instruction bundles the agent can invoke from its local tool environment.
|
||||
This view shows the current instructions file and the skills currently visible to the local agent runtime.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Agent: <span className="font-mono">{agent.name}</span>
|
||||
</p>
|
||||
<div className="rounded-md border border-border bg-muted/30 px-3 py-2 text-sm">
|
||||
<div className="text-xs uppercase tracking-wide text-muted-foreground mb-1">
|
||||
Instructions file
|
||||
</div>
|
||||
<div className="font-mono break-all">
|
||||
{instructionsPath ?? "No instructions file configured for this agent."}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Available skills
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading available skills…</p>
|
||||
) : error ? (
|
||||
<p className="text-sm text-destructive">
|
||||
{error instanceof Error ? error.message : "Failed to load available skills."}
|
||||
</p>
|
||||
) : skills.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No local skills were found.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{skills.map((skill) => (
|
||||
<SkillRow key={skill.name} skill={skill} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SkillRow({ skill }: { skill: AvailableSkill }) {
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-muted/20 px-3 py-2 space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-sm">{skill.name}</span>
|
||||
<Badge variant={skill.isPaperclipManaged ? "secondary" : "outline"}>
|
||||
{skill.isPaperclipManaged ? "Paperclip" : "Local"}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{skill.description || "No description available."}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---- Runs Tab ---- */
|
||||
|
||||
function RunListItem({ run, isSelected, agentId }: { run: HeartbeatRun; isSelected: boolean; agentId: string }) {
|
||||
|
||||
@@ -211,10 +211,10 @@ export function ProjectDetail() {
|
||||
const { companies, selectedCompanyId, setSelectedCompanyId } = useCompany();
|
||||
const { closePanel } = usePanel();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const { pushToast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { pushToast } = useToast();
|
||||
const [fieldSaveStates, setFieldSaveStates] = useState<Partial<Record<ProjectConfigFieldKey, ProjectFieldSaveState>>>({});
|
||||
const fieldSaveRequestIds = useRef<Partial<Record<ProjectConfigFieldKey, number>>>({});
|
||||
const fieldSaveTimers = useRef<Partial<Record<ProjectConfigFieldKey, ReturnType<typeof setTimeout>>>>({});
|
||||
@@ -286,13 +286,14 @@ export function ProjectDetail() {
|
||||
{ archivedAt: archived ? new Date().toISOString() : null },
|
||||
resolvedCompanyId ?? lookupCompanyId,
|
||||
),
|
||||
onSuccess: (_, archived) => {
|
||||
onSuccess: (updatedProject, archived) => {
|
||||
invalidateProject();
|
||||
const name = updatedProject?.name ?? project?.name ?? "Project";
|
||||
if (archived) {
|
||||
pushToast({ title: "Project archived", tone: "success" });
|
||||
pushToast({ title: `"${name}" has been archived`, tone: "success" });
|
||||
navigate("/dashboard");
|
||||
} else {
|
||||
pushToast({ title: "Project unarchived", tone: "success" });
|
||||
pushToast({ title: `"${name}" has been unarchived`, tone: "success" });
|
||||
}
|
||||
},
|
||||
onError: (_, archived) => {
|
||||
@@ -454,8 +455,24 @@ export function ProjectDetail() {
|
||||
return <Navigate to={`/projects/${canonicalProjectRef}/issues`} replace />;
|
||||
}
|
||||
|
||||
// Redirect bare /projects/:id to /projects/:id/issues
|
||||
// Redirect bare /projects/:id to cached tab or default /issues
|
||||
if (routeProjectRef && activeTab === null) {
|
||||
let cachedTab: string | null = null;
|
||||
if (project?.id) {
|
||||
try { cachedTab = localStorage.getItem(`paperclip:project-tab:${project.id}`); } catch {}
|
||||
}
|
||||
if (cachedTab === "overview") {
|
||||
return <Navigate to={`/projects/${canonicalProjectRef}/overview`} replace />;
|
||||
}
|
||||
if (cachedTab === "configuration") {
|
||||
return <Navigate to={`/projects/${canonicalProjectRef}/configuration`} replace />;
|
||||
}
|
||||
if (cachedTab === "budget") {
|
||||
return <Navigate to={`/projects/${canonicalProjectRef}/budget`} replace />;
|
||||
}
|
||||
if (isProjectPluginTab(cachedTab)) {
|
||||
return <Navigate to={`/projects/${canonicalProjectRef}?tab=${encodeURIComponent(cachedTab)}`} replace />;
|
||||
}
|
||||
return <Navigate to={`/projects/${canonicalProjectRef}/issues`} replace />;
|
||||
}
|
||||
|
||||
@@ -464,6 +481,10 @@ export function ProjectDetail() {
|
||||
if (!project) return null;
|
||||
|
||||
const handleTabChange = (tab: ProjectTab) => {
|
||||
// Cache the active tab per project
|
||||
if (project?.id) {
|
||||
try { localStorage.setItem(`paperclip:project-tab:${project.id}`, tab); } catch {}
|
||||
}
|
||||
if (isProjectPluginTab(tab)) {
|
||||
navigate(`/projects/${canonicalProjectRef}?tab=${encodeURIComponent(tab)}`);
|
||||
return;
|
||||
@@ -538,8 +559,8 @@ export function ProjectDetail() {
|
||||
<Tabs value={activeTab ?? "list"} onValueChange={(value) => handleTabChange(value as ProjectTab)}>
|
||||
<PageTabBar
|
||||
items={[
|
||||
{ value: "list", label: "Issues" },
|
||||
{ value: "overview", label: "Overview" },
|
||||
{ value: "list", label: "List" },
|
||||
{ value: "configuration", label: "Configuration" },
|
||||
{ value: "budget", label: "Budget" },
|
||||
...pluginTabItems.map((item) => ({
|
||||
|
||||
Reference in New Issue
Block a user