paperclip/ui/src/pages/InstanceGeneralSettings.tsx
Dotta a26e1288b6
[codex] Polish issue board workflows (#4224)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - Human operators supervise that work through issue lists, issue
detail, comments, inbox groups, markdown references, and
profile/activity surfaces
> - The branch had many small UI fixes that improve the operator loop
but do not need to ship with backend runtime migrations
> - These changes belong together as board workflow polish because they
affect scanning, navigation, issue context, comment state, and markdown
clarity
> - This pull request groups the UI-only slice so it can merge
independently from runtime/backend changes
> - The benefit is a clearer board experience with better issue context,
steadier optimistic updates, and more predictable keyboard navigation

## What Changed

- Improves issue properties, sub-issue actions, blocker chips, and issue
list/detail refresh behavior.
- Adds blocker context above the issue composer and stabilizes
queued/interrupted comment UI state.
- Improves markdown issue/GitHub link rendering and opens external
markdown links in a new tab.
- Adds inbox group keyboard navigation and fold/unfold support.
- Polishes activity/avatar/profile/settings/workspace presentation
details.

## Verification

- `pnpm exec vitest run ui/src/components/IssueProperties.test.tsx
ui/src/components/IssueChatThread.test.tsx
ui/src/components/MarkdownBody.test.tsx ui/src/lib/inbox.test.ts
ui/src/lib/optimistic-issue-comments.test.ts`

## Risks

- Low to medium risk: changes are UI-focused but cover high-traffic
issue and inbox surfaces.
- This branch intentionally does not include the backend runtime changes
from the companion PR; where UI calls newer API filters, unsupported
servers should continue to fail visibly through existing API error
handling.
- Visual screenshots were not captured in this heartbeat; targeted
component/helper tests cover the changed behavior.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5-based coding agent runtime, shell/git tool use
enabled. Exact hosted model build and context window are not exposed in
this Paperclip heartbeat environment.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge
2026-04-21 12:25:34 -05:00

382 lines
16 KiB
TypeScript

import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { PatchInstanceGeneralSettings, BackupRetentionPolicy } from "@paperclipai/shared";
import {
DAILY_RETENTION_PRESETS,
WEEKLY_RETENTION_PRESETS,
MONTHLY_RETENTION_PRESETS,
DEFAULT_BACKUP_RETENTION,
} from "@paperclipai/shared";
import { LogOut, SlidersHorizontal } from "lucide-react";
import { authApi } from "@/api/auth";
import { healthApi } from "@/api/health";
import { instanceSettingsApi } from "@/api/instanceSettings";
import { ModeBadge } from "@/components/access/ModeBadge";
import { Button } from "../components/ui/button";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys";
import { ToggleSwitch } from "@/components/ui/toggle-switch";
import { cn } from "../lib/utils";
const FEEDBACK_TERMS_URL = import.meta.env.VITE_FEEDBACK_TERMS_URL?.trim() || "https://paperclip.ing/tos";
export function InstanceGeneralSettings() {
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
const [actionError, setActionError] = useState<string | null>(null);
const signOutMutation = useMutation({
mutationFn: () => authApi.signOut(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.auth.session });
},
onError: (error) => {
setActionError(error instanceof Error ? error.message : "Failed to sign out.");
},
});
useEffect(() => {
setBreadcrumbs([
{ label: "Instance Settings" },
{ label: "General" },
]);
}, [setBreadcrumbs]);
const generalQuery = useQuery({
queryKey: queryKeys.instance.generalSettings,
queryFn: () => instanceSettingsApi.getGeneral(),
});
const healthQuery = useQuery({
queryKey: queryKeys.health,
queryFn: () => healthApi.get(),
retry: false,
});
const updateGeneralMutation = useMutation({
mutationFn: instanceSettingsApi.updateGeneral,
onSuccess: async () => {
setActionError(null);
await queryClient.invalidateQueries({ queryKey: queryKeys.instance.generalSettings });
},
onError: (error) => {
setActionError(error instanceof Error ? error.message : "Failed to update general settings.");
},
});
if (generalQuery.isLoading) {
return <div className="text-sm text-muted-foreground">Loading general settings...</div>;
}
if (generalQuery.error) {
return (
<div className="text-sm text-destructive">
{generalQuery.error instanceof Error
? generalQuery.error.message
: "Failed to load general settings."}
</div>
);
}
const censorUsernameInLogs = generalQuery.data?.censorUsernameInLogs === true;
const keyboardShortcuts = generalQuery.data?.keyboardShortcuts === true;
const feedbackDataSharingPreference = generalQuery.data?.feedbackDataSharingPreference ?? "prompt";
const backupRetention: BackupRetentionPolicy = generalQuery.data?.backupRetention ?? DEFAULT_BACKUP_RETENTION;
return (
<div className="max-w-4xl space-y-6">
<div className="space-y-2">
<div className="flex items-center gap-2">
<SlidersHorizontal className="h-5 w-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">General</h1>
</div>
<p className="text-sm text-muted-foreground">
Configure instance-wide preferences including log display, keyboard shortcuts, backup
retention, and data sharing.
</p>
</div>
{actionError && (
<div className="rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm text-destructive">
{actionError}
</div>
)}
<section className="rounded-xl border border-border bg-card p-5">
<div className="space-y-3">
<div className="flex items-center gap-2">
<h2 className="text-sm font-semibold">Deployment and auth</h2>
<ModeBadge
deploymentMode={healthQuery.data?.deploymentMode}
deploymentExposure={healthQuery.data?.deploymentExposure}
/>
</div>
<div className="text-sm text-muted-foreground">
{healthQuery.data?.deploymentMode === "local_trusted"
? "Local trusted mode is optimized for a local operator. Browser requests run as local board context and no sign-in is required."
: healthQuery.data?.deploymentExposure === "public"
? "Authenticated public mode requires sign-in for board access and is intended for public URLs."
: "Authenticated private mode requires sign-in and is intended for LAN, VPN, or other private-network deployments."}
</div>
<div className="grid gap-3 md:grid-cols-3">
<StatusBox
label="Auth readiness"
value={healthQuery.data?.authReady ? "Ready" : "Not ready"}
/>
<StatusBox
label="Bootstrap status"
value={healthQuery.data?.bootstrapStatus === "bootstrap_pending" ? "Setup required" : "Ready"}
/>
<StatusBox
label="Bootstrap invite"
value={healthQuery.data?.bootstrapInviteActive ? "Active" : "None"}
/>
</div>
</div>
</section>
<section className="rounded-xl border border-border bg-card p-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1.5">
<h2 className="text-sm font-semibold">Censor username in logs</h2>
<p className="max-w-2xl text-sm text-muted-foreground">
Hide the username segment in home-directory paths and similar operator-visible log output. Standalone
username mentions outside of paths are not yet masked in the live transcript view. This is off by
default.
</p>
</div>
<ToggleSwitch
checked={censorUsernameInLogs}
onCheckedChange={() => updateGeneralMutation.mutate({ censorUsernameInLogs: !censorUsernameInLogs })}
disabled={updateGeneralMutation.isPending}
aria-label="Toggle username log censoring"
/>
</div>
</section>
<section className="rounded-xl border border-border bg-card p-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1.5">
<h2 className="text-sm font-semibold">Keyboard shortcuts</h2>
<p className="max-w-2xl text-sm text-muted-foreground">
Enable app keyboard shortcuts, including inbox navigation and global shortcuts like creating issues or
toggling panels. This is off by default.
</p>
</div>
<ToggleSwitch
checked={keyboardShortcuts}
onCheckedChange={() => updateGeneralMutation.mutate({ keyboardShortcuts: !keyboardShortcuts })}
disabled={updateGeneralMutation.isPending}
aria-label="Toggle keyboard shortcuts"
/>
</div>
</section>
<section className="rounded-xl border border-border bg-card p-5">
<div className="space-y-5">
<div className="space-y-1.5">
<h2 className="text-sm font-semibold">Backup retention</h2>
<p className="max-w-2xl text-sm text-muted-foreground">
Configure how long automatic database backups are retained. Backups run roughly
every hour and are compressed with gzip. Within the daily window all backups are
kept; beyond that, one backup per week and one per month are preserved.
</p>
</div>
<div className="space-y-1.5">
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Daily</h3>
<div className="flex flex-wrap gap-2">
{DAILY_RETENTION_PRESETS.map((days) => {
const active = backupRetention.dailyDays === days;
return (
<button
key={days}
type="button"
disabled={updateGeneralMutation.isPending}
className={cn(
"rounded-lg border px-3 py-2 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-60",
active
? "border-foreground bg-accent text-foreground"
: "border-border bg-background hover:bg-accent/50",
)}
onClick={() =>
updateGeneralMutation.mutate({
backupRetention: { ...backupRetention, dailyDays: days },
})
}
>
<div className="text-sm font-medium">{days} days</div>
</button>
);
})}
</div>
</div>
<div className="space-y-1.5">
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Weekly</h3>
<div className="flex flex-wrap gap-2">
{WEEKLY_RETENTION_PRESETS.map((weeks) => {
const active = backupRetention.weeklyWeeks === weeks;
const label = weeks === 1 ? "1 week" : `${weeks} weeks`;
return (
<button
key={weeks}
type="button"
disabled={updateGeneralMutation.isPending}
className={cn(
"rounded-lg border px-3 py-2 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-60",
active
? "border-foreground bg-accent text-foreground"
: "border-border bg-background hover:bg-accent/50",
)}
onClick={() =>
updateGeneralMutation.mutate({
backupRetention: { ...backupRetention, weeklyWeeks: weeks },
})
}
>
<div className="text-sm font-medium">{label}</div>
</button>
);
})}
</div>
</div>
<div className="space-y-1.5">
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Monthly</h3>
<div className="flex flex-wrap gap-2">
{MONTHLY_RETENTION_PRESETS.map((months) => {
const active = backupRetention.monthlyMonths === months;
const label = months === 1 ? "1 month" : `${months} months`;
return (
<button
key={months}
type="button"
disabled={updateGeneralMutation.isPending}
className={cn(
"rounded-lg border px-3 py-2 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-60",
active
? "border-foreground bg-accent text-foreground"
: "border-border bg-background hover:bg-accent/50",
)}
onClick={() =>
updateGeneralMutation.mutate({
backupRetention: { ...backupRetention, monthlyMonths: months },
})
}
>
<div className="text-sm font-medium">{label}</div>
</button>
);
})}
</div>
</div>
</div>
</section>
<section className="rounded-xl border border-border bg-card p-5">
<div className="space-y-4">
<div className="space-y-1.5">
<h2 className="text-sm font-semibold">AI feedback sharing</h2>
<p className="max-w-2xl text-sm text-muted-foreground">
Control whether thumbs up and thumbs down votes can send the voted AI output to
Paperclip Labs. Votes are always saved locally.
</p>
{FEEDBACK_TERMS_URL ? (
<a
href={FEEDBACK_TERMS_URL}
target="_blank"
rel="noreferrer"
className="inline-flex text-sm text-muted-foreground underline underline-offset-4 hover:text-foreground"
>
Read our terms of service
</a>
) : null}
</div>
{feedbackDataSharingPreference === "prompt" ? (
<div className="rounded-lg border border-border/70 bg-accent/20 px-3 py-2 text-sm text-muted-foreground">
No default is saved yet. The next thumbs up or thumbs down choice will ask once and
then save the answer here.
</div>
) : null}
<div className="flex flex-wrap gap-2">
{[
{
value: "allowed",
label: "Always allow",
description: "Share voted AI outputs automatically.",
},
{
value: "not_allowed",
label: "Don't allow",
description: "Keep voted AI outputs local only.",
},
].map((option) => {
const active = feedbackDataSharingPreference === option.value;
return (
<button
key={option.value}
type="button"
disabled={updateGeneralMutation.isPending}
className={cn(
"rounded-lg border px-3 py-2 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-60",
active
? "border-foreground bg-accent text-foreground"
: "border-border bg-background hover:bg-accent/50",
)}
onClick={() =>
updateGeneralMutation.mutate({
feedbackDataSharingPreference: option.value as
| "allowed"
| "not_allowed",
})
}
>
<div className="text-sm font-medium">{option.label}</div>
<div className="text-xs text-muted-foreground">
{option.description}
</div>
</button>
);
})}
</div>
<p className="text-xs text-muted-foreground">
To retest the first-use prompt in local dev, remove the{" "}
<code>feedbackDataSharingPreference</code> key from the{" "}
<code>instance_settings.general</code> JSON row for this instance, or set it back to{" "}
<code>"prompt"</code>. Unset and <code>"prompt"</code> both mean no default has been
chosen yet.
</p>
</div>
</section>
<section className="rounded-xl border border-border bg-card p-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1.5">
<h2 className="text-sm font-semibold">Sign out</h2>
<p className="max-w-2xl text-sm text-muted-foreground">
Sign out of this Paperclip instance. You will be redirected to the login page.
</p>
</div>
<Button
variant="outline"
size="sm"
disabled={signOutMutation.isPending}
onClick={() => signOutMutation.mutate()}
>
<LogOut className="size-4" />
{signOutMutation.isPending ? "Signing out..." : "Sign out"}
</Button>
</div>
</section>
</div>
);
}
function StatusBox({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-lg border border-border bg-background px-3 py-3">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">{label}</div>
<div className="mt-2 text-sm font-medium">{value}</div>
</div>
);
}