mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-17 19:20:39 +09:00
[codex] Improve workspace runtime and navigation ergonomics (#3680)
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - That operator experience depends not just on issue chat, but also on how workspaces, inbox groups, and navigation state behave over long-running sessions > - The current branch included a separate cluster of workspace-runtime controls, inbox grouping, sidebar ordering, and worktree lifecycle fixes > - Those changes cross server, shared contracts, database state, and UI navigation, but they still form one coherent operator workflow area > - This pull request isolates the workspace/runtime and navigation ergonomics work into one standalone branch > - The benefit is better workspace recovery and navigation persistence without forcing reviewers through the unrelated issue-detail/chat work ## What Changed - Improved execution workspace and project workspace controls, request wiring, layout, and JSON editor ergonomics - Hardened linked worktree reuse/startup behavior and documented the `worktree repair` flow for recovering linked worktrees safely - Added inbox workspace grouping, mobile collapse, archive undo, keyboard navigation, shared group-header styling, and persisted collapsed-group behavior - Added persistent sidebar order preferences with the supporting DB migration, shared/server contracts, routes, services, hooks, and UI integration - Scoped issue-list preferences by context and added targeted UI/server tests for workspace controls, inbox behavior, sidebar preferences, and worktree validation ## Verification - `pnpm vitest run server/src/__tests__/sidebar-preferences-routes.test.ts ui/src/pages/Inbox.test.tsx ui/src/components/ProjectWorkspaceSummaryCard.test.tsx ui/src/components/WorkspaceRuntimeControls.test.tsx ui/src/api/workspace-runtime-control.test.ts` - `server/src/__tests__/workspace-runtime.test.ts` was attempted, but the embedded Postgres suite self-skipped/hung on this host after reporting an init-script issue, so it is not counted as a local pass here ## Risks - Medium: this branch includes migration-backed preference storage plus worktree/runtime behavior, so merge review should pay attention to state persistence and worktree recovery semantics - The sidebar preference migration is standalone, but it should still be watched for conflicts if another migration lands first ## Model Used - OpenAI Codex coding agent (GPT-5-class runtime in Codex CLI; exact deployed model ID is not exposed in this environment), reasoning enabled, tool use and local code execution enabled ## 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) - [ ] 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 --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
6e6f538630
commit
e89076148a
64 changed files with 18576 additions and 1063 deletions
|
|
@ -26,10 +26,8 @@ import {
|
|||
import {
|
||||
DEFAULT_INBOX_ISSUE_COLUMNS,
|
||||
getAvailableInboxIssueColumns,
|
||||
loadInboxIssueColumns,
|
||||
normalizeInboxIssueColumns,
|
||||
resolveIssueWorkspaceName,
|
||||
saveInboxIssueColumns,
|
||||
type InboxIssueColumn,
|
||||
} from "../lib/inbox";
|
||||
import { cn } from "../lib/utils";
|
||||
|
|
@ -43,13 +41,14 @@ import {
|
|||
import { StatusIcon } from "./StatusIcon";
|
||||
import { EmptyState } from "./EmptyState";
|
||||
import { Identity } from "./Identity";
|
||||
import { IssueGroupHeader } from "./IssueGroupHeader";
|
||||
import { IssueFiltersPopover } from "./IssueFiltersPopover";
|
||||
import { IssueRow } from "./IssueRow";
|
||||
import { PageSkeleton } from "./PageSkeleton";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
|
||||
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible";
|
||||
import { Collapsible, CollapsibleContent } from "@/components/ui/collapsible";
|
||||
import { CircleDot, Plus, ArrowUpDown, Layers, Check, ChevronRight, List, ListTree, Columns3, User, Search } from "lucide-react";
|
||||
import { KanbanBoard } from "./KanbanBoard";
|
||||
import { buildIssueTree, countDescendants } from "../lib/issue-tree";
|
||||
|
|
@ -79,6 +78,7 @@ const defaultViewState: IssueViewState = {
|
|||
collapsedGroups: [],
|
||||
collapsedParents: [],
|
||||
};
|
||||
|
||||
function getViewState(key: string): IssueViewState {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
|
|
@ -91,6 +91,43 @@ function saveViewState(key: string, state: IssueViewState) {
|
|||
localStorage.setItem(key, JSON.stringify(state));
|
||||
}
|
||||
|
||||
function getInitialViewState(key: string, initialAssignees?: string[]): IssueViewState {
|
||||
const stored = getViewState(key);
|
||||
if (!initialAssignees) return stored;
|
||||
return {
|
||||
...stored,
|
||||
assignees: initialAssignees,
|
||||
statuses: [],
|
||||
};
|
||||
}
|
||||
|
||||
function getIssueColumnsStorageKey(key: string): string {
|
||||
return `${key}:issue-columns`;
|
||||
}
|
||||
|
||||
function loadIssueColumns(key: string): InboxIssueColumn[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(getIssueColumnsStorageKey(key));
|
||||
if (raw === null) return DEFAULT_INBOX_ISSUE_COLUMNS;
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return DEFAULT_INBOX_ISSUE_COLUMNS;
|
||||
return normalizeInboxIssueColumns(parsed);
|
||||
} catch {
|
||||
return DEFAULT_INBOX_ISSUE_COLUMNS;
|
||||
}
|
||||
}
|
||||
|
||||
function saveIssueColumns(key: string, columns: InboxIssueColumn[]) {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
getIssueColumnsStorageKey(key),
|
||||
JSON.stringify(normalizeInboxIssueColumns(columns)),
|
||||
);
|
||||
} catch {
|
||||
// Ignore localStorage failures.
|
||||
}
|
||||
}
|
||||
|
||||
function sortIssues(issues: Issue[], state: IssueViewState): Issue[] {
|
||||
const sorted = [...issues];
|
||||
const dir = state.sortDir === "asc" ? 1 : -1;
|
||||
|
|
@ -240,17 +277,13 @@ export function IssuesList({
|
|||
|
||||
// Scope the storage key per company so folding/view state is independent across companies.
|
||||
const scopedKey = selectedCompanyId ? `${viewStateKey}:${selectedCompanyId}` : viewStateKey;
|
||||
const initialAssigneesKey = initialAssignees?.join("|") ?? "";
|
||||
|
||||
const [viewState, setViewState] = useState<IssueViewState>(() => {
|
||||
if (initialAssignees) {
|
||||
return { ...defaultViewState, assignees: initialAssignees, statuses: [] };
|
||||
}
|
||||
return getViewState(scopedKey);
|
||||
});
|
||||
const [viewState, setViewState] = useState<IssueViewState>(() => getInitialViewState(scopedKey, initialAssignees));
|
||||
const [assigneePickerIssueId, setAssigneePickerIssueId] = useState<string | null>(null);
|
||||
const [assigneeSearch, setAssigneeSearch] = useState("");
|
||||
const [issueSearch, setIssueSearch] = useState(initialSearch ?? "");
|
||||
const [visibleIssueColumns, setVisibleIssueColumns] = useState<InboxIssueColumn[]>(loadInboxIssueColumns);
|
||||
const [visibleIssueColumns, setVisibleIssueColumns] = useState<InboxIssueColumn[]>(() => loadIssueColumns(scopedKey));
|
||||
const deferredIssueSearch = useDeferredValue(issueSearch);
|
||||
const normalizedIssueSearch = deferredIssueSearch.trim().toLowerCase();
|
||||
|
||||
|
|
@ -258,16 +291,23 @@ export function IssuesList({
|
|||
setIssueSearch(initialSearch ?? "");
|
||||
}, [initialSearch]);
|
||||
|
||||
// Reload view state from localStorage when company changes (scopedKey changes).
|
||||
const prevScopedKey = useRef(scopedKey);
|
||||
// Reload view state whenever the persisted context changes.
|
||||
const prevViewStateContextKey = useRef(`${scopedKey}::${initialAssigneesKey}`);
|
||||
useEffect(() => {
|
||||
if (prevScopedKey.current !== scopedKey) {
|
||||
prevScopedKey.current = scopedKey;
|
||||
setViewState(initialAssignees
|
||||
? { ...defaultViewState, assignees: initialAssignees, statuses: [] }
|
||||
: getViewState(scopedKey));
|
||||
const nextContextKey = `${scopedKey}::${initialAssigneesKey}`;
|
||||
if (prevViewStateContextKey.current !== nextContextKey) {
|
||||
prevViewStateContextKey.current = nextContextKey;
|
||||
setViewState(getInitialViewState(scopedKey, initialAssignees));
|
||||
}
|
||||
}, [scopedKey, initialAssignees]);
|
||||
}, [scopedKey, initialAssignees, initialAssigneesKey]);
|
||||
|
||||
const prevColumnsScopedKey = useRef(scopedKey);
|
||||
useEffect(() => {
|
||||
if (prevColumnsScopedKey.current !== scopedKey) {
|
||||
prevColumnsScopedKey.current = scopedKey;
|
||||
setVisibleIssueColumns(loadIssueColumns(scopedKey));
|
||||
}
|
||||
}, [scopedKey]);
|
||||
|
||||
const updateView = useCallback((patch: Partial<IssueViewState>) => {
|
||||
setViewState((prev) => {
|
||||
|
|
@ -521,8 +561,8 @@ export function IssuesList({
|
|||
const setIssueColumns = useCallback((next: InboxIssueColumn[]) => {
|
||||
const normalized = normalizeInboxIssueColumns(next);
|
||||
setVisibleIssueColumns(normalized);
|
||||
saveInboxIssueColumns(normalized);
|
||||
}, []);
|
||||
saveIssueColumns(scopedKey, normalized);
|
||||
}, [scopedKey]);
|
||||
|
||||
const toggleIssueColumn = useCallback((column: InboxIssueColumn, enabled: boolean) => {
|
||||
if (enabled) {
|
||||
|
|
@ -723,22 +763,28 @@ export function IssuesList({
|
|||
}}
|
||||
>
|
||||
{group.label && (
|
||||
<div className="flex items-center py-1.5 pl-1 pr-3">
|
||||
<CollapsibleTrigger className="flex items-center gap-1.5">
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform [[data-state=open]>&]:rotate-90" />
|
||||
<span className="text-sm font-semibold uppercase tracking-wide">
|
||||
{group.label}
|
||||
</span>
|
||||
</CollapsibleTrigger>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="ml-auto text-muted-foreground"
|
||||
onClick={() => openCreateIssueDialog(group.key)}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<IssueGroupHeader
|
||||
label={group.label}
|
||||
collapsible
|
||||
collapsed={viewState.collapsedGroups.includes(group.key)}
|
||||
onToggle={() => {
|
||||
updateView({
|
||||
collapsedGroups: viewState.collapsedGroups.includes(group.key)
|
||||
? viewState.collapsedGroups.filter((k) => k !== group.key)
|
||||
: [...viewState.collapsedGroups, group.key],
|
||||
});
|
||||
}}
|
||||
trailing={(
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="text-muted-foreground"
|
||||
onClick={() => openCreateIssueDialog(group.key)}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<CollapsibleContent>
|
||||
{(() => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue