e89076148a
## 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>
261 lines
9.0 KiB
TypeScript
261 lines
9.0 KiB
TypeScript
import { useCallback, useMemo } from "react";
|
|
import { Paperclip, Plus } from "lucide-react";
|
|
import { useQueries, useQuery } from "@tanstack/react-query";
|
|
import {
|
|
DndContext,
|
|
closestCenter,
|
|
MouseSensor,
|
|
useSensor,
|
|
useSensors,
|
|
type DragEndEvent,
|
|
} from "@dnd-kit/core";
|
|
import {
|
|
SortableContext,
|
|
useSortable,
|
|
verticalListSortingStrategy,
|
|
arrayMove,
|
|
} from "@dnd-kit/sortable";
|
|
import { CSS } from "@dnd-kit/utilities";
|
|
import { useCompany } from "../context/CompanyContext";
|
|
import { useDialog } from "../context/DialogContext";
|
|
import { cn } from "../lib/utils";
|
|
import { queryKeys } from "../lib/queryKeys";
|
|
import { sidebarBadgesApi } from "../api/sidebarBadges";
|
|
import { heartbeatsApi } from "../api/heartbeats";
|
|
import { authApi } from "../api/auth";
|
|
import { useCompanyOrder } from "../hooks/useCompanyOrder";
|
|
import { useLocation, useNavigate } from "@/lib/router";
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipTrigger,
|
|
} from "@/components/ui/tooltip";
|
|
import type { Company } from "@paperclipai/shared";
|
|
import { CompanyPatternIcon } from "./CompanyPatternIcon";
|
|
|
|
function SortableCompanyItem({
|
|
company,
|
|
isSelected,
|
|
hasLiveAgents,
|
|
hasUnreadInbox,
|
|
onSelect,
|
|
}: {
|
|
company: Company;
|
|
isSelected: boolean;
|
|
hasLiveAgents: boolean;
|
|
hasUnreadInbox: boolean;
|
|
onSelect: () => void;
|
|
}) {
|
|
const {
|
|
attributes,
|
|
listeners,
|
|
setNodeRef,
|
|
transform,
|
|
transition,
|
|
isDragging,
|
|
} = useSortable({ id: company.id });
|
|
|
|
const style = {
|
|
transform: CSS.Transform.toString(transform),
|
|
transition,
|
|
zIndex: isDragging ? 10 : undefined,
|
|
opacity: isDragging ? 0.8 : 1,
|
|
};
|
|
|
|
return (
|
|
<div ref={setNodeRef} style={style} {...attributes} {...listeners} className="overflow-visible">
|
|
<Tooltip delayDuration={300}>
|
|
<TooltipTrigger asChild>
|
|
<a
|
|
href={`/${company.issuePrefix}/dashboard`}
|
|
onClick={(e) => {
|
|
if (isDragging) {
|
|
e.preventDefault();
|
|
return;
|
|
}
|
|
e.preventDefault();
|
|
onSelect();
|
|
}}
|
|
className="relative flex items-center justify-center group overflow-visible"
|
|
>
|
|
{/* Selection indicator pill */}
|
|
<div
|
|
className={cn(
|
|
"absolute left-[-14px] w-1 rounded-r-full bg-foreground transition-[height] duration-150",
|
|
isSelected
|
|
? "h-5"
|
|
: "h-0 group-hover:h-2"
|
|
)}
|
|
/>
|
|
<div
|
|
className={cn("relative overflow-visible transition-transform duration-150", isDragging && "scale-105")}
|
|
>
|
|
<CompanyPatternIcon
|
|
companyName={company.name}
|
|
logoUrl={company.logoUrl}
|
|
brandColor={company.brandColor}
|
|
className={cn(
|
|
isSelected
|
|
? "rounded-[14px]"
|
|
: "rounded-[22px] group-hover:rounded-[14px]",
|
|
isDragging && "shadow-lg",
|
|
)}
|
|
/>
|
|
{hasLiveAgents && (
|
|
<span className="pointer-events-none absolute -right-0.5 -top-0.5 z-10">
|
|
<span className="relative flex h-2.5 w-2.5">
|
|
<span className="absolute inline-flex h-full w-full animate-pulse rounded-full bg-blue-400 opacity-80" />
|
|
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-blue-500 ring-2 ring-background" />
|
|
</span>
|
|
</span>
|
|
)}
|
|
{hasUnreadInbox && (
|
|
<span className="pointer-events-none absolute -bottom-0.5 -right-0.5 z-10 h-2.5 w-2.5 rounded-full bg-red-500 ring-2 ring-background" />
|
|
)}
|
|
</div>
|
|
</a>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="right" sideOffset={8}>
|
|
<p>{company.name}</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function CompanyRail() {
|
|
const { companies, selectedCompanyId, setSelectedCompanyId } = useCompany();
|
|
const { openOnboarding } = useDialog();
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
const isInstanceRoute = location.pathname.startsWith("/instance/");
|
|
const highlightedCompanyId = isInstanceRoute ? null : selectedCompanyId;
|
|
const sidebarCompanies = useMemo(
|
|
() => companies.filter((company) => company.status !== "archived"),
|
|
[companies],
|
|
);
|
|
const { data: session } = useQuery({
|
|
queryKey: queryKeys.auth.session,
|
|
queryFn: () => authApi.getSession(),
|
|
});
|
|
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
|
|
const companyIds = useMemo(() => sidebarCompanies.map((company) => company.id), [sidebarCompanies]);
|
|
|
|
const liveRunsQueries = useQueries({
|
|
queries: companyIds.map((companyId) => ({
|
|
queryKey: queryKeys.liveRuns(companyId),
|
|
queryFn: () => heartbeatsApi.liveRunsForCompany(companyId),
|
|
refetchInterval: 10_000,
|
|
})),
|
|
});
|
|
const sidebarBadgeQueries = useQueries({
|
|
queries: companyIds.map((companyId) => ({
|
|
queryKey: queryKeys.sidebarBadges(companyId),
|
|
queryFn: () => sidebarBadgesApi.get(companyId),
|
|
refetchInterval: 15_000,
|
|
})),
|
|
});
|
|
const hasLiveAgentsByCompanyId = useMemo(() => {
|
|
const result = new Map<string, boolean>();
|
|
companyIds.forEach((companyId, index) => {
|
|
result.set(companyId, (liveRunsQueries[index]?.data?.length ?? 0) > 0);
|
|
});
|
|
return result;
|
|
}, [companyIds, liveRunsQueries]);
|
|
const hasUnreadInboxByCompanyId = useMemo(() => {
|
|
const result = new Map<string, boolean>();
|
|
companyIds.forEach((companyId, index) => {
|
|
result.set(companyId, (sidebarBadgeQueries[index]?.data?.inbox ?? 0) > 0);
|
|
});
|
|
return result;
|
|
}, [companyIds, sidebarBadgeQueries]);
|
|
|
|
const { orderedCompanies, persistOrder } = useCompanyOrder({
|
|
companies: sidebarCompanies,
|
|
userId: currentUserId,
|
|
});
|
|
|
|
// Require 8px of movement before starting a drag to avoid interfering with clicks
|
|
const sensors = useSensors(
|
|
// Keep sidebar reordering mouse-only so touch input can scroll/tap without drag affordances.
|
|
useSensor(MouseSensor, {
|
|
activationConstraint: { distance: 8 },
|
|
})
|
|
);
|
|
|
|
const handleDragEnd = useCallback(
|
|
(event: DragEndEvent) => {
|
|
const { active, over } = event;
|
|
if (!over || active.id === over.id) return;
|
|
|
|
const ids = orderedCompanies.map((c) => c.id);
|
|
const oldIndex = ids.indexOf(active.id as string);
|
|
const newIndex = ids.indexOf(over.id as string);
|
|
if (oldIndex === -1 || newIndex === -1) return;
|
|
|
|
persistOrder(arrayMove(ids, oldIndex, newIndex));
|
|
},
|
|
[orderedCompanies, persistOrder]
|
|
);
|
|
|
|
return (
|
|
<div className="flex flex-col items-center w-[72px] shrink-0 h-full bg-background border-r border-border">
|
|
{/* Paperclip icon - aligned with top sections (implied line, no visible border) */}
|
|
<div className="flex items-center justify-center h-12 w-full shrink-0">
|
|
<Paperclip className="h-5 w-5 text-foreground" />
|
|
</div>
|
|
|
|
{/* Company list */}
|
|
<div className="flex-1 flex flex-col items-center gap-2 py-3 w-full overflow-y-auto overflow-x-hidden scrollbar-none">
|
|
<DndContext
|
|
sensors={sensors}
|
|
collisionDetection={closestCenter}
|
|
onDragEnd={handleDragEnd}
|
|
>
|
|
<SortableContext
|
|
items={orderedCompanies.map((c) => c.id)}
|
|
strategy={verticalListSortingStrategy}
|
|
>
|
|
{orderedCompanies.map((company) => (
|
|
<SortableCompanyItem
|
|
key={company.id}
|
|
company={company}
|
|
isSelected={company.id === highlightedCompanyId}
|
|
hasLiveAgents={hasLiveAgentsByCompanyId.get(company.id) ?? false}
|
|
hasUnreadInbox={hasUnreadInboxByCompanyId.get(company.id) ?? false}
|
|
onSelect={() => {
|
|
setSelectedCompanyId(company.id);
|
|
if (isInstanceRoute) {
|
|
navigate(`/${company.issuePrefix}/dashboard`);
|
|
}
|
|
}}
|
|
/>
|
|
))}
|
|
</SortableContext>
|
|
</DndContext>
|
|
</div>
|
|
|
|
{/* Separator before add button */}
|
|
<div className="w-8 h-px bg-border mx-auto shrink-0" />
|
|
|
|
{/* Add company button */}
|
|
<div className="flex items-center justify-center py-2 shrink-0">
|
|
<Tooltip delayDuration={300}>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
onClick={() => openOnboarding()}
|
|
className="flex items-center justify-center w-11 h-11 rounded-[22px] hover:rounded-[14px] border-2 border-dashed border-border text-muted-foreground hover:border-foreground/30 hover:text-foreground transition-[border-color,color,border-radius] duration-150"
|
|
aria-label="Add company"
|
|
>
|
|
<Plus className="h-5 w-5" />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="right" sideOffset={8}>
|
|
<p>Add company</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|