feat(ui): mobile UX improvements, comment attachments, and cost breakdown

Add PWA meta tags for iOS home screen. Fix mobile properties drawer with safe
area insets. Add image attachment button to comment thread. Improve sidebar
with collapsible sections, project grouping, and mobile bottom nav. Show
token and billing type breakdown on costs page. Fix inbox loading state to
show content progressively. Various mobile overflow and layout fixes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Forgotten
2026-02-25 21:36:06 -06:00
parent b9dad31eb1
commit 33d549db13
16 changed files with 688 additions and 228 deletions

View File

@@ -4,6 +4,9 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta name="theme-color" content="#18181b" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Paperclip" />
<title>Paperclip</title>
<link rel="icon" href="/favicon.ico" sizes="48x48" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />

View File

@@ -1,7 +1,8 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
import { Link } from "react-router-dom";
import type { IssueComment, Agent } from "@paperclip/shared";
import { Button } from "@/components/ui/button";
import { Paperclip } from "lucide-react";
import { Identity } from "./Identity";
import { MarkdownBody } from "./MarkdownBody";
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor";
@@ -18,7 +19,10 @@ interface CommentThreadProps {
issueStatus?: string;
agentMap?: Map<string, Agent>;
imageUploadHandler?: (file: File) => Promise<string>;
/** Callback to attach an image file to the parent issue (not inline in a comment). */
onAttachImage?: (file: File) => Promise<void>;
draftKey?: string;
liveRunSlot?: React.ReactNode;
}
const CLOSED_STATUSES = new Set(["done", "cancelled"]);
@@ -52,11 +56,13 @@ function clearDraft(draftKey: string) {
}
}
export function CommentThread({ comments, onAdd, issueStatus, agentMap, imageUploadHandler, draftKey }: CommentThreadProps) {
export function CommentThread({ comments, onAdd, issueStatus, agentMap, imageUploadHandler, onAttachImage, draftKey, liveRunSlot }: CommentThreadProps) {
const [body, setBody] = useState("");
const [reopen, setReopen] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [attaching, setAttaching] = useState(false);
const editorRef = useRef<MarkdownEditorRef>(null);
const attachInputRef = useRef<HTMLInputElement | null>(null);
const draftTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const isClosed = issueStatus ? CLOSED_STATUSES.has(issueStatus) : false;
@@ -112,6 +118,18 @@ export function CommentThread({ comments, onAdd, issueStatus, agentMap, imageUpl
}
}
async function handleAttachFile(evt: ChangeEvent<HTMLInputElement>) {
const file = evt.target.files?.[0];
if (!file || !onAttachImage) return;
setAttaching(true);
try {
await onAttachImage(file);
} finally {
setAttaching(false);
if (attachInputRef.current) attachInputRef.current.value = "";
}
}
return (
<div className="space-y-4">
<h3 className="text-sm font-semibold">Comments ({comments.length})</h3>
@@ -122,7 +140,7 @@ export function CommentThread({ comments, onAdd, issueStatus, agentMap, imageUpl
<div className="space-y-3">
{sorted.map((comment) => (
<div key={comment.id} className="border border-border p-3">
<div key={comment.id} className="border border-border p-3 overflow-hidden min-w-0">
<div className="flex items-center justify-between mb-1">
{comment.authorAgentId ? (
<Link to={`/agents/${comment.authorAgentId}`} className="hover:underline">
@@ -153,6 +171,8 @@ export function CommentThread({ comments, onAdd, issueStatus, agentMap, imageUpl
))}
</div>
{liveRunSlot}
<div className="space-y-2">
<MarkdownEditor
ref={editorRef}
@@ -165,6 +185,27 @@ export function CommentThread({ comments, onAdd, issueStatus, agentMap, imageUpl
contentClassName="min-h-[60px] text-sm"
/>
<div className="flex items-center justify-end gap-3">
{onAttachImage && (
<>
<input
ref={attachInputRef}
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
className="hidden"
onChange={handleAttachFile}
/>
<Button
variant="ghost"
size="icon-sm"
className="mr-auto"
onClick={() => attachInputRef.current?.click()}
disabled={attaching}
title="Attach image"
>
<Paperclip className="h-4 w-4" />
</Button>
</>
)}
{isClosed && (
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer select-none">
<input

View File

@@ -28,7 +28,7 @@ export function InlineEditor({
}: InlineEditorProps) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
setDraft(value);
@@ -44,11 +44,11 @@ export function InlineEditor({
if (editing && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
if (multiline && inputRef.current instanceof HTMLTextAreaElement) {
if (inputRef.current instanceof HTMLTextAreaElement) {
autoSize(inputRef.current);
}
}
}, [editing, multiline, autoSize]);
}, [editing, autoSize]);
function commit() {
const trimmed = draft.trim();
@@ -101,25 +101,19 @@ export function InlineEditor({
);
}
const sharedProps = {
ref: inputRef as any,
value: draft,
onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setDraft(e.target.value);
if (multiline && e.target instanceof HTMLTextAreaElement) {
autoSize(e.target);
}
},
onBlur: commit,
onKeyDown: handleKeyDown,
};
return (
<input
type="text"
{...sharedProps}
<textarea
ref={inputRef}
value={draft}
rows={1}
onChange={(e) => {
setDraft(e.target.value);
autoSize(e.target);
}}
onBlur={commit}
onKeyDown={handleKeyDown}
className={cn(
"w-full bg-transparent rounded outline-none",
"w-full bg-transparent rounded outline-none resize-none overflow-hidden",
pad,
className
)}

View File

@@ -154,7 +154,7 @@ export function IssueProperties({ issue, onUpdate }: IssuePropertiesProps) {
)}
</button>
</PopoverTrigger>
<PopoverContent className="w-64 p-1" align="end">
<PopoverContent className="w-64 p-1" align="end" collisionPadding={16}>
<input
className="w-full px-2 py-1.5 text-xs bg-transparent outline-none border-b border-border mb-1 placeholder:text-muted-foreground/50"
placeholder="Search labels..."
@@ -241,7 +241,7 @@ export function IssueProperties({ issue, onUpdate }: IssuePropertiesProps) {
)}
</button>
</PopoverTrigger>
<PopoverContent className="w-52 p-1" align="end">
<PopoverContent className="w-52 p-1" align="end" collisionPadding={16}>
<input
className="w-full px-2 py-1.5 text-xs bg-transparent outline-none border-b border-border mb-1 placeholder:text-muted-foreground/50"
placeholder="Search agents..."
@@ -313,7 +313,7 @@ export function IssueProperties({ issue, onUpdate }: IssuePropertiesProps) {
)}
</button>
</PopoverTrigger>
<PopoverContent className="w-fit min-w-[11rem] p-1" align="end">
<PopoverContent className="w-fit min-w-[11rem] p-1" align="end" collisionPadding={16}>
<input
className="w-full px-2 py-1.5 text-xs bg-transparent outline-none border-b border-border mb-1 placeholder:text-muted-foreground/50"
placeholder="Search projects..."

View File

@@ -499,7 +499,7 @@ export function IssuesList({
groupedContent.map((group) => (
<Collapsible key={group.key} defaultOpen>
{group.label && (
<div className="flex items-center py-1.5 pl-1">
<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">
@@ -523,8 +523,8 @@ export function IssuesList({
to={`/issues/${issue.identifier ?? issue.id}`}
className="flex items-center gap-2 py-2 pl-1 pr-3 text-sm border-b border-border last:border-b-0 cursor-pointer hover:bg-accent/50 transition-colors no-underline text-inherit"
>
{/* Spacer matching caret width so status icon aligns with group title */}
<div className="w-3.5 shrink-0" />
{/* Spacer matching caret width so status icon aligns with group title (hidden on mobile) */}
<div className="w-3.5 shrink-0 hidden sm:block" />
<div className="shrink-0" onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>
<StatusIcon
status={issue.status}
@@ -556,84 +556,86 @@ export function IssuesList({
</div>
)}
<div className="flex items-center gap-2 sm:gap-3 shrink-0 ml-auto">
<Popover
open={assigneePickerIssueId === issue.id}
onOpenChange={(open) => {
setAssigneePickerIssueId(open ? issue.id : null);
if (!open) setAssigneeSearch("");
}}
>
<PopoverTrigger asChild>
<button
className="flex w-[180px] shrink-0 items-center rounded-md px-2 py-1 hover:bg-accent/50 transition-colors"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
{issue.assigneeAgentId && agentName(issue.assigneeAgentId) ? (
<Identity name={agentName(issue.assigneeAgentId)!} size="sm" />
) : (
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-dashed border-muted-foreground/35 bg-muted/30">
<User className="h-3 w-3" />
</span>
Assignee
</span>
)}
</button>
</PopoverTrigger>
<PopoverContent
className="w-56 p-1"
align="end"
onClick={(e) => e.stopPropagation()}
onPointerDownOutside={() => setAssigneeSearch("")}
<div className="hidden sm:block">
<Popover
open={assigneePickerIssueId === issue.id}
onOpenChange={(open) => {
setAssigneePickerIssueId(open ? issue.id : null);
if (!open) setAssigneeSearch("");
}}
>
<input
className="w-full px-2 py-1.5 text-xs bg-transparent outline-none border-b border-border mb-1 placeholder:text-muted-foreground/50"
placeholder="Search agents..."
value={assigneeSearch}
onChange={(e) => setAssigneeSearch(e.target.value)}
autoFocus
/>
<div className="max-h-48 overflow-y-auto overscroll-contain">
<PopoverTrigger asChild>
<button
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
!issue.assigneeAgentId && "bg-accent"
)}
className="flex w-[180px] shrink-0 items-center rounded-md px-2 py-1 hover:bg-accent/50 transition-colors"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
assignIssue(issue.id, null);
}}
>
No assignee
{issue.assigneeAgentId && agentName(issue.assigneeAgentId) ? (
<Identity name={agentName(issue.assigneeAgentId)!} size="sm" />
) : (
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-dashed border-muted-foreground/35 bg-muted/30">
<User className="h-3 w-3" />
</span>
Assignee
</span>
)}
</button>
{(agents ?? [])
.filter((agent) => {
if (!assigneeSearch.trim()) return true;
return agent.name.toLowerCase().includes(assigneeSearch.toLowerCase());
})
.map((agent) => (
<button
key={agent.id}
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-left",
issue.assigneeAgentId === agent.id && "bg-accent"
)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
assignIssue(issue.id, agent.id);
}}
>
<Identity name={agent.name} size="sm" className="min-w-0" />
</button>
))}
</div>
</PopoverContent>
</Popover>
</PopoverTrigger>
<PopoverContent
className="w-56 p-1"
align="end"
onClick={(e) => e.stopPropagation()}
onPointerDownOutside={() => setAssigneeSearch("")}
>
<input
className="w-full px-2 py-1.5 text-xs bg-transparent outline-none border-b border-border mb-1 placeholder:text-muted-foreground/50"
placeholder="Search agents..."
value={assigneeSearch}
onChange={(e) => setAssigneeSearch(e.target.value)}
autoFocus
/>
<div className="max-h-48 overflow-y-auto overscroll-contain">
<button
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
!issue.assigneeAgentId && "bg-accent"
)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
assignIssue(issue.id, null);
}}
>
No assignee
</button>
{(agents ?? [])
.filter((agent) => {
if (!assigneeSearch.trim()) return true;
return agent.name.toLowerCase().includes(assigneeSearch.toLowerCase());
})
.map((agent) => (
<button
key={agent.id}
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-left",
issue.assigneeAgentId === agent.id && "bg-accent"
)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
assignIssue(issue.id, agent.id);
}}
>
<Identity name={agent.name} size="sm" className="min-w-0" />
</button>
))}
</div>
</PopoverContent>
</Popover>
</div>
{liveIssueIds?.has(issue.id) && (
<span className="inline-flex items-center gap-1 sm:gap-1.5 px-1.5 sm:px-2 py-0.5 rounded-full bg-blue-500/10">
<span className="relative flex h-2 w-2">

View File

@@ -101,7 +101,7 @@ export function Layout() {
);
return (
<div className="flex h-screen bg-background text-foreground overflow-hidden pt-[env(safe-area-inset-top)]">
<div className="flex h-dvh bg-background text-foreground overflow-hidden pt-[env(safe-area-inset-top)]">
{/* Mobile backdrop */}
{isMobile && sidebarOpen && (
<div
@@ -114,18 +114,16 @@ export function Layout() {
{isMobile ? (
<div
className={cn(
"fixed inset-y-0 left-0 z-50 flex pt-[env(safe-area-inset-top)] transition-transform duration-200 ease-in-out",
"fixed inset-y-0 left-0 z-50 flex flex-col overflow-hidden pt-[env(safe-area-inset-top)] transition-transform duration-200 ease-in-out",
sidebarOpen ? "translate-x-0" : "-translate-x-full"
)}
>
<div className="flex flex-col h-full">
<div className="flex flex-1 min-h-0">
<CompanyRail />
<Sidebar />
</div>
<div className="border-t border-r border-border px-3 py-2 bg-background">
<SidebarNavItem to="/docs" label="Documentation" icon={BookOpen} />
</div>
<div className="flex flex-1 min-h-0 overflow-hidden">
<CompanyRail />
<Sidebar />
</div>
<div className="border-t border-r border-border px-3 py-2 bg-background">
<SidebarNavItem to="/docs" label="Documentation" icon={BookOpen} />
</div>
</div>
) : (
@@ -152,7 +150,7 @@ export function Layout() {
<BreadcrumbBar />
<div className="flex flex-1 min-h-0">
<main
className={cn("flex-1 overflow-auto p-4 md:p-6", isMobile && "pb-24")}
className={cn("flex-1 overflow-auto p-4 md:p-6", isMobile && "pb-[calc(5rem+env(safe-area-inset-bottom))]")}
onScroll={handleMainScroll}
>
<Outlet />

View File

@@ -1,13 +1,13 @@
import { useEffect, useMemo, useRef, useState, type MutableRefObject } from "react";
import { Link } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import type { LiveEvent } from "@paperclip/shared";
import { heartbeatsApi, type LiveRunForIssue } from "../api/heartbeats";
import { getUIAdapter } from "../adapters";
import type { TranscriptEntry } from "../adapters";
import { queryKeys } from "../lib/queryKeys";
import { cn, relativeTime } from "../lib/utils";
import { ExternalLink } from "lucide-react";
import { ExternalLink, Square } from "lucide-react";
import { Identity } from "./Identity";
interface LiveRunWidgetProps {
@@ -142,13 +142,29 @@ function parseStderrChunk(
}
export function LiveRunWidget({ issueId, companyId }: LiveRunWidgetProps) {
const queryClient = useQueryClient();
const [feed, setFeed] = useState<FeedItem[]>([]);
const [cancellingRunIds, setCancellingRunIds] = useState(new Set<string>());
const seenKeysRef = useRef(new Set<string>());
const pendingByRunRef = useRef(new Map<string, string>());
const runMetaByIdRef = useRef(new Map<string, { agentId: string; agentName: string }>());
const nextIdRef = useRef(1);
const bodyRef = useRef<HTMLDivElement>(null);
const handleCancelRun = async (runId: string) => {
setCancellingRunIds((prev) => new Set(prev).add(runId));
try {
await heartbeatsApi.cancel(runId);
queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(issueId) });
} finally {
setCancellingRunIds((prev) => {
const next = new Set(prev);
next.delete(runId);
return next;
});
}
};
const { data: liveRuns } = useQuery({
queryKey: queryKeys.issues.liveRuns(issueId),
queryFn: () => heartbeatsApi.liveRunsForIssue(issueId),
@@ -323,13 +339,25 @@ export function LiveRunWidget({ issueId, companyId }: LiveRunWidgetProps) {
</span>
</div>
{headerRun && (
<Link
to={`/agents/${headerRun.agentId}/runs/${headerRun.id}`}
className="inline-flex items-center gap-1 text-[10px] text-cyan-300 hover:text-cyan-200"
>
Open run
<ExternalLink className="h-2.5 w-2.5" />
</Link>
<div className="flex items-center gap-2">
{runs.length > 0 && (
<button
onClick={() => handleCancelRun(headerRun.id)}
disabled={cancellingRunIds.has(headerRun.id)}
className="inline-flex items-center gap-1 text-[10px] text-red-400 hover:text-red-300 disabled:opacity-50"
>
<Square className="h-2 w-2" fill="currentColor" />
{cancellingRunIds.has(headerRun.id) ? "Stopping…" : "Stop"}
</button>
)}
<Link
to={`/agents/${headerRun.agentId}/runs/${headerRun.id}`}
className="inline-flex items-center gap-1 text-[10px] text-cyan-300 hover:text-cyan-200"
>
Open run
<ExternalLink className="h-2.5 w-2.5" />
</Link>
</div>
)}
</div>
@@ -362,17 +390,18 @@ export function LiveRunWidget({ issueId, companyId }: LiveRunWidgetProps) {
))}
</div>
{runs.length > 0 && (
{runs.length > 1 && (
<div className="border-t border-border/50 px-3 py-2 flex flex-wrap gap-2">
{runs.map((run) => (
<Link
key={run.id}
to={`/agents/${run.agentId}/runs/${run.id}`}
className="inline-flex items-center gap-1 text-[10px] text-cyan-300 hover:text-cyan-200"
>
<Identity name={run.agentName} size="sm" /> {run.id.slice(0, 8)}
<ExternalLink className="h-2.5 w-2.5" />
</Link>
<div key={run.id} className="inline-flex items-center gap-1.5">
<Link
to={`/agents/${run.agentId}/runs/${run.id}`}
className="inline-flex items-center gap-1 text-[10px] text-cyan-300 hover:text-cyan-200"
>
<Identity name={run.agentName} size="sm" /> {run.id.slice(0, 8)}
<ExternalLink className="h-2.5 w-2.5" />
</Link>
</div>
))}
</div>
)}

View File

@@ -66,12 +66,12 @@ export function MobileBottomNav({ visible }: MobileBottomNavProps) {
return (
<nav
className={cn(
"fixed bottom-0 left-0 right-0 z-30 border-t border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/85 transition-transform duration-200 ease-out md:hidden",
"fixed bottom-0 left-0 right-0 z-30 border-t border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/85 transition-transform duration-200 ease-out md:hidden pb-[env(safe-area-inset-bottom)]",
visible ? "translate-y-0" : "translate-y-full",
)}
aria-label="Mobile navigation"
>
<div className="grid h-16 grid-cols-5 px-1 pb-[env(safe-area-inset-bottom)]">
<div className="grid h-16 grid-cols-5 px-1">
{items.map((item) => {
if (item.type === "action") {
const Icon = item.icon;

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { useState, useEffect, useRef, useCallback, type ChangeEvent } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useDialog } from "../context/DialogContext";
import { useCompany } from "../context/CompanyContext";
@@ -31,6 +31,7 @@ import {
Hexagon,
Tag,
Calendar,
Paperclip,
} from "lucide-react";
import { cn } from "../lib/utils";
import { issueStatusText, issueStatusTextDefault, priorityColor, priorityColorDefault } from "../lib/status-colors";
@@ -105,6 +106,7 @@ export function NewIssueDialog() {
const [projectOpen, setProjectOpen] = useState(false);
const [moreOpen, setMoreOpen] = useState(false);
const descriptionEditorRef = useRef<MarkdownEditorRef>(null);
const attachInputRef = useRef<HTMLInputElement | null>(null);
const { data: agents } = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId!),
@@ -223,6 +225,21 @@ export function NewIssueDialog() {
}
}
async function handleAttachImage(evt: ChangeEvent<HTMLInputElement>) {
const file = evt.target.files?.[0];
if (!file) return;
try {
const asset = await uploadDescriptionImage.mutateAsync(file);
const name = file.name || "image";
setDescription((prev) => {
const suffix = `![${name}](${asset.contentPath})`;
return prev ? `${prev}\n\n${suffix}` : suffix;
});
} finally {
if (attachInputRef.current) attachInputRef.current.value = "";
}
}
const hasDraft = title.trim().length > 0 || description.trim().length > 0;
const currentStatus = statuses.find((s) => s.value === status) ?? statuses[1]!;
const currentPriority = priorities.find((p) => p.value === priority);
@@ -488,6 +505,23 @@ export function NewIssueDialog() {
Labels
</button>
{/* Attach image chip */}
<input
ref={attachInputRef}
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
className="hidden"
onChange={handleAttachImage}
/>
<button
className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors text-muted-foreground"
onClick={() => attachInputRef.current?.click()}
disabled={uploadDescriptionImage.isPending}
>
<Paperclip className="h-3 w-3" />
{uploadDescriptionImage.isPending ? "Uploading..." : "Image"}
</button>
{/* More (dates) */}
<Popover open={moreOpen} onOpenChange={setMoreOpen}>
<PopoverTrigger asChild>

View File

@@ -23,6 +23,9 @@ import {
Calendar,
Plus,
X,
FolderOpen,
Github,
GitBranch,
} from "lucide-react";
import { PROJECT_COLORS } from "@paperclip/shared";
import { cn } from "../lib/utils";
@@ -37,6 +40,9 @@ const projectStatuses = [
{ value: "cancelled", label: "Cancelled" },
];
type WorkspaceSetup = "none" | "local" | "repo" | "both";
const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__";
export function NewProjectDialog() {
const { newProjectOpen, closeNewProject } = useDialog();
const { selectedCompanyId, selectedCompany } = useCompany();
@@ -47,6 +53,10 @@ export function NewProjectDialog() {
const [goalIds, setGoalIds] = useState<string[]>([]);
const [targetDate, setTargetDate] = useState("");
const [expanded, setExpanded] = useState(false);
const [workspaceSetup, setWorkspaceSetup] = useState<WorkspaceSetup>("none");
const [workspaceLocalPath, setWorkspaceLocalPath] = useState("");
const [workspaceRepoUrl, setWorkspaceRepoUrl] = useState("");
const [workspaceError, setWorkspaceError] = useState<string | null>(null);
const [statusOpen, setStatusOpen] = useState(false);
const [goalOpen, setGoalOpen] = useState(false);
@@ -61,11 +71,6 @@ export function NewProjectDialog() {
const createProject = useMutation({
mutationFn: (data: Record<string, unknown>) =>
projectsApi.create(selectedCompanyId!, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.projects.list(selectedCompanyId!) });
reset();
closeNewProject();
},
});
const uploadDescriptionImage = useMutation({
@@ -82,18 +87,108 @@ export function NewProjectDialog() {
setGoalIds([]);
setTargetDate("");
setExpanded(false);
setWorkspaceSetup("none");
setWorkspaceLocalPath("");
setWorkspaceRepoUrl("");
setWorkspaceError(null);
}
function handleSubmit() {
const isAbsolutePath = (value: string) => value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value);
const isGitHubRepoUrl = (value: string) => {
try {
const parsed = new URL(value);
const host = parsed.hostname.toLowerCase();
if (host !== "github.com" && host !== "www.github.com") return false;
const segments = parsed.pathname.split("/").filter(Boolean);
return segments.length >= 2;
} catch {
return false;
}
};
const deriveWorkspaceNameFromPath = (value: string) => {
const normalized = value.trim().replace(/[\\/]+$/, "");
const segments = normalized.split(/[\\/]/).filter(Boolean);
return segments[segments.length - 1] ?? "Local folder";
};
const deriveWorkspaceNameFromRepo = (value: string) => {
try {
const parsed = new URL(value);
const segments = parsed.pathname.split("/").filter(Boolean);
const repo = segments[segments.length - 1]?.replace(/\.git$/i, "") ?? "";
return repo || "GitHub repo";
} catch {
return "GitHub repo";
}
};
const toggleWorkspaceSetup = (next: WorkspaceSetup) => {
setWorkspaceSetup((prev) => (prev === next ? "none" : next));
setWorkspaceError(null);
};
async function handleSubmit() {
if (!selectedCompanyId || !name.trim()) return;
createProject.mutate({
name: name.trim(),
description: description.trim() || undefined,
status,
color: PROJECT_COLORS[Math.floor(Math.random() * PROJECT_COLORS.length)],
...(goalIds.length > 0 ? { goalIds } : {}),
...(targetDate ? { targetDate } : {}),
});
const localRequired = workspaceSetup === "local" || workspaceSetup === "both";
const repoRequired = workspaceSetup === "repo" || workspaceSetup === "both";
const localPath = workspaceLocalPath.trim();
const repoUrl = workspaceRepoUrl.trim();
if (localRequired && !isAbsolutePath(localPath)) {
setWorkspaceError("Local folder must be a full absolute path.");
return;
}
if (repoRequired && !isGitHubRepoUrl(repoUrl)) {
setWorkspaceError("Repo workspace must use a valid GitHub repo URL.");
return;
}
setWorkspaceError(null);
try {
const created = await createProject.mutateAsync({
name: name.trim(),
description: description.trim() || undefined,
status,
color: PROJECT_COLORS[Math.floor(Math.random() * PROJECT_COLORS.length)],
...(goalIds.length > 0 ? { goalIds } : {}),
...(targetDate ? { targetDate } : {}),
});
const workspacePayloads: Array<Record<string, unknown>> = [];
if (localRequired && repoRequired) {
workspacePayloads.push({
name: deriveWorkspaceNameFromPath(localPath),
cwd: localPath,
repoUrl,
});
} else if (localRequired) {
workspacePayloads.push({
name: deriveWorkspaceNameFromPath(localPath),
cwd: localPath,
});
} else if (repoRequired) {
workspacePayloads.push({
name: deriveWorkspaceNameFromRepo(repoUrl),
cwd: REPO_ONLY_CWD_SENTINEL,
repoUrl,
});
}
for (const workspacePayload of workspacePayloads) {
await projectsApi.createWorkspace(created.id, {
...workspacePayload,
});
}
queryClient.invalidateQueries({ queryKey: queryKeys.projects.list(selectedCompanyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(created.id) });
reset();
closeNewProject();
} catch {
// surface through createProject.isError
}
}
function handleKeyDown(e: React.KeyboardEvent) {
@@ -185,6 +280,83 @@ export function NewProjectDialog() {
/>
</div>
<div className="px-4 pb-3 space-y-3 border-t border-border">
<div className="pt-3">
<p className="text-sm font-medium">Where will work be done on this project?</p>
<p className="text-xs text-muted-foreground">Add local folder and/or GitHub repo workspace hints.</p>
</div>
<div className="grid gap-2 sm:grid-cols-3">
<button
type="button"
className={cn(
"rounded-lg border px-3 py-3 text-left transition-colors",
workspaceSetup === "local" ? "border-foreground bg-accent/40" : "border-border hover:bg-accent/30",
)}
onClick={() => toggleWorkspaceSetup("local")}
>
<div className="flex items-center gap-2 text-sm font-medium">
<FolderOpen className="h-4 w-4" />
A local folder
</div>
<p className="mt-1 text-xs text-muted-foreground">Use a full path on this machine.</p>
</button>
<button
type="button"
className={cn(
"rounded-lg border px-3 py-3 text-left transition-colors",
workspaceSetup === "repo" ? "border-foreground bg-accent/40" : "border-border hover:bg-accent/30",
)}
onClick={() => toggleWorkspaceSetup("repo")}
>
<div className="flex items-center gap-2 text-sm font-medium">
<Github className="h-4 w-4" />
A github repo
</div>
<p className="mt-1 text-xs text-muted-foreground">Paste a GitHub URL.</p>
</button>
<button
type="button"
className={cn(
"rounded-lg border px-3 py-3 text-left transition-colors",
workspaceSetup === "both" ? "border-foreground bg-accent/40" : "border-border hover:bg-accent/30",
)}
onClick={() => toggleWorkspaceSetup("both")}
>
<div className="flex items-center gap-2 text-sm font-medium">
<GitBranch className="h-4 w-4" />
Both
</div>
<p className="mt-1 text-xs text-muted-foreground">Configure local + repo hints.</p>
</button>
</div>
{(workspaceSetup === "local" || workspaceSetup === "both") && (
<div className="rounded-md border border-border p-2">
<label className="mb-1 block text-xs text-muted-foreground">Local folder (full path)</label>
<input
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs font-mono outline-none"
value={workspaceLocalPath}
onChange={(e) => setWorkspaceLocalPath(e.target.value)}
placeholder="/absolute/path/to/workspace"
/>
</div>
)}
{(workspaceSetup === "repo" || workspaceSetup === "both") && (
<div className="rounded-md border border-border p-2">
<label className="mb-1 block text-xs text-muted-foreground">GitHub repo URL</label>
<input
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs outline-none"
value={workspaceRepoUrl}
onChange={(e) => setWorkspaceRepoUrl(e.target.value)}
placeholder="https://github.com/org/repo"
/>
</div>
)}
{workspaceError && (
<p className="text-xs text-destructive">{workspaceError}</p>
)}
</div>
{/* Property chips */}
<div className="flex items-center gap-1.5 px-4 py-2 border-t border-border flex-wrap">
{/* Status */}
@@ -281,7 +453,12 @@ export function NewProjectDialog() {
</div>
{/* Footer */}
<div className="flex items-center justify-end px-4 py-2.5 border-t border-border">
<div className="flex items-center justify-between px-4 py-2.5 border-t border-border">
{createProject.isError ? (
<p className="text-xs text-destructive">Failed to create project.</p>
) : (
<span />
)}
<Button
size="sm"
disabled={!name.trim() || createProject.isPending}

View File

@@ -11,13 +11,16 @@ import { queryKeys } from "../lib/queryKeys";
import { Separator } from "@/components/ui/separator";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Plus, Star, Trash2, X } from "lucide-react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { ExternalLink, Github, Plus, Trash2, X } from "lucide-react";
interface ProjectPropertiesProps {
project: Project;
onUpdate?: (data: Record<string, unknown>) => void;
}
const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__";
function PropertyRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex items-center gap-3 py-1.5">
@@ -31,9 +34,10 @@ export function ProjectProperties({ project, onUpdate }: ProjectPropertiesProps)
const { selectedCompanyId } = useCompany();
const queryClient = useQueryClient();
const [goalOpen, setGoalOpen] = useState(false);
const [workspaceName, setWorkspaceName] = useState("");
const [workspaceMode, setWorkspaceMode] = useState<"local" | "repo" | null>(null);
const [workspaceCwd, setWorkspaceCwd] = useState("");
const [workspaceRepoUrl, setWorkspaceRepoUrl] = useState("");
const [workspaceError, setWorkspaceError] = useState<string | null>(null);
const { data: allGoals } = useQuery({
queryKey: queryKeys.goals.list(selectedCompanyId!),
@@ -67,19 +71,14 @@ export function ProjectProperties({ project, onUpdate }: ProjectPropertiesProps)
const createWorkspace = useMutation({
mutationFn: (data: Record<string, unknown>) => projectsApi.createWorkspace(project.id, data),
onSuccess: () => {
setWorkspaceName("");
setWorkspaceCwd("");
setWorkspaceRepoUrl("");
setWorkspaceMode(null);
setWorkspaceError(null);
invalidateProject();
},
});
const updateWorkspace = useMutation({
mutationFn: (input: { workspaceId: string; data: Record<string, unknown> }) =>
projectsApi.updateWorkspace(project.id, input.workspaceId, input.data),
onSuccess: invalidateProject,
});
const removeWorkspace = useMutation({
mutationFn: (workspaceId: string) => projectsApi.removeWorkspace(project.id, workspaceId),
onSuccess: invalidateProject,
@@ -96,13 +95,75 @@ export function ProjectProperties({ project, onUpdate }: ProjectPropertiesProps)
setGoalOpen(false);
};
const submitWorkspace = () => {
if (!workspaceName.trim() || !workspaceCwd.trim()) return;
const isAbsolutePath = (value: string) => value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value);
const isGitHubRepoUrl = (value: string) => {
try {
const parsed = new URL(value);
const host = parsed.hostname.toLowerCase();
if (host !== "github.com" && host !== "www.github.com") return false;
const segments = parsed.pathname.split("/").filter(Boolean);
return segments.length >= 2;
} catch {
return false;
}
};
const deriveWorkspaceNameFromPath = (value: string) => {
const normalized = value.trim().replace(/[\\/]+$/, "");
const segments = normalized.split(/[\\/]/).filter(Boolean);
return segments[segments.length - 1] ?? "Local folder";
};
const deriveWorkspaceNameFromRepo = (value: string) => {
try {
const parsed = new URL(value);
const segments = parsed.pathname.split("/").filter(Boolean);
const repo = segments[segments.length - 1]?.replace(/\.git$/i, "") ?? "";
return repo || "GitHub repo";
} catch {
return "GitHub repo";
}
};
const formatGitHubRepo = (value: string) => {
try {
const parsed = new URL(value);
const segments = parsed.pathname.split("/").filter(Boolean);
if (segments.length < 2) return value;
const owner = segments[0];
const repo = segments[1]?.replace(/\.git$/i, "");
if (!owner || !repo) return value;
return `${owner}/${repo}`;
} catch {
return value;
}
};
const submitLocalWorkspace = () => {
const cwd = workspaceCwd.trim();
if (!isAbsolutePath(cwd)) {
setWorkspaceError("Local folder must be a full absolute path.");
return;
}
setWorkspaceError(null);
createWorkspace.mutate({
name: workspaceName.trim(),
cwd: workspaceCwd.trim(),
repoUrl: workspaceRepoUrl.trim() || null,
isPrimary: workspaces.length === 0,
name: deriveWorkspaceNameFromPath(cwd),
cwd,
});
};
const submitRepoWorkspace = () => {
const repoUrl = workspaceRepoUrl.trim();
if (!isGitHubRepoUrl(repoUrl)) {
setWorkspaceError("Repo workspace must use a valid GitHub repo URL.");
return;
}
setWorkspaceError(null);
createWorkspace.mutate({
name: deriveWorkspaceNameFromRepo(repoUrl),
cwd: REPO_ONLY_CWD_SENTINEL,
repoUrl,
});
};
@@ -193,78 +254,179 @@ export function ProjectProperties({ project, onUpdate }: ProjectPropertiesProps)
<div className="space-y-1">
<div className="py-1.5 space-y-2">
<div className="text-xs text-muted-foreground">Workspaces</div>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span>Workspaces</span>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex h-4 w-4 items-center justify-center rounded-full border border-border text-[10px] text-muted-foreground hover:text-foreground"
aria-label="Workspaces help"
>
?
</button>
</TooltipTrigger>
<TooltipContent side="top">
Workspaces give your agents hints about where the work is
</TooltipContent>
</Tooltip>
</div>
{workspaces.length === 0 ? (
<p className="text-sm text-muted-foreground">No project workspaces configured.</p>
<p className="rounded-md border border-dashed border-border px-3 py-2 text-sm text-muted-foreground">
No workspace configured.
</p>
) : (
<div className="space-y-2">
<div className="space-y-1">
{workspaces.map((workspace) => (
<div key={workspace.id} className="rounded-md border border-border p-2 space-y-1">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium truncate">{workspace.name}</span>
<div className="flex items-center gap-1">
{workspace.isPrimary ? (
<span className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] border border-border text-muted-foreground">
<Star className="h-2.5 w-2.5" />
Primary
</span>
) : (
<Button
variant="outline"
size="xs"
className="h-5 px-1.5 text-[10px]"
onClick={() => updateWorkspace.mutate({ workspaceId: workspace.id, data: { isPrimary: true } })}
>
Set primary
</Button>
)}
<div key={workspace.id} className="space-y-1">
{workspace.cwd && workspace.cwd !== REPO_ONLY_CWD_SENTINEL ? (
<div className="flex items-center justify-between gap-2 py-1">
<span className="min-w-0 truncate font-mono text-xs text-muted-foreground">{workspace.cwd}</span>
<Button
variant="ghost"
size="icon-xs"
onClick={() => removeWorkspace.mutate(workspace.id)}
aria-label={`Delete workspace ${workspace.name}`}
onClick={() => {
const confirmed = window.confirm("Delete this workspace?");
if (confirmed) {
removeWorkspace.mutate(workspace.id);
}
}}
aria-label="Delete workspace"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
<p className="text-xs font-mono text-muted-foreground break-all">{workspace.cwd}</p>
{workspace.repoUrl && (
<p className="text-xs text-muted-foreground truncate">{workspace.repoUrl}</p>
)}
) : null}
{workspace.repoUrl ? (
<div className="flex items-center justify-between gap-2 py-1">
<a
href={workspace.repoUrl}
target="_blank"
rel="noreferrer"
className="inline-flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground hover:underline"
>
<Github className="h-3 w-3 shrink-0" />
<span className="truncate">{formatGitHubRepo(workspace.repoUrl)}</span>
<ExternalLink className="h-3 w-3 shrink-0" />
</a>
<Button
variant="ghost"
size="icon-xs"
onClick={() => {
const confirmed = window.confirm("Delete this workspace?");
if (confirmed) {
removeWorkspace.mutate(workspace.id);
}
}}
aria-label="Delete workspace"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
) : null}
</div>
))}
</div>
)}
<div className="space-y-1.5 rounded-md border border-border p-2">
<input
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs outline-none"
value={workspaceName}
onChange={(e) => setWorkspaceName(e.target.value)}
placeholder="Workspace name"
/>
<input
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs font-mono outline-none"
value={workspaceCwd}
onChange={(e) => setWorkspaceCwd(e.target.value)}
placeholder="/absolute/path/to/workspace"
/>
<input
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs outline-none"
value={workspaceRepoUrl}
onChange={(e) => setWorkspaceRepoUrl(e.target.value)}
placeholder="Repo URL (optional)"
/>
<div className="flex flex-col items-start gap-2">
<Button
variant="outline"
size="xs"
className="h-6 px-2"
disabled={!workspaceName.trim() || !workspaceCwd.trim() || createWorkspace.isPending}
onClick={submitWorkspace}
className="h-7 px-2.5"
onClick={() => {
setWorkspaceMode("local");
setWorkspaceError(null);
}}
>
Add workspace
Add workspace local folder
</Button>
<Button
variant="outline"
size="xs"
className="h-7 px-2.5"
onClick={() => {
setWorkspaceMode("repo");
setWorkspaceError(null);
}}
>
Add workspace repo
</Button>
</div>
{workspaceMode === "local" && (
<div className="space-y-1.5 rounded-md border border-border p-2">
<input
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs font-mono outline-none"
value={workspaceCwd}
onChange={(e) => setWorkspaceCwd(e.target.value)}
placeholder="/absolute/path/to/workspace"
/>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="xs"
className="h-6 px-2"
disabled={!workspaceCwd.trim() || createWorkspace.isPending}
onClick={submitLocalWorkspace}
>
Save
</Button>
<Button
variant="ghost"
size="xs"
className="h-6 px-2"
onClick={() => {
setWorkspaceMode(null);
setWorkspaceCwd("");
setWorkspaceError(null);
}}
>
Cancel
</Button>
</div>
</div>
)}
{workspaceMode === "repo" && (
<div className="space-y-1.5 rounded-md border border-border p-2">
<input
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs outline-none"
value={workspaceRepoUrl}
onChange={(e) => setWorkspaceRepoUrl(e.target.value)}
placeholder="https://github.com/org/repo"
/>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="xs"
className="h-6 px-2"
disabled={!workspaceRepoUrl.trim() || createWorkspace.isPending}
onClick={submitRepoWorkspace}
>
Save
</Button>
<Button
variant="ghost"
size="xs"
className="h-6 px-2"
onClick={() => {
setWorkspaceMode(null);
setWorkspaceRepoUrl("");
setWorkspaceError(null);
}}
>
Cancel
</Button>
</div>
</div>
)}
{workspaceError && (
<p className="text-xs text-destructive">{workspaceError}</p>
)}
{createWorkspace.isError && (
<p className="text-xs text-destructive">Failed to save workspace.</p>
)}
{removeWorkspace.isError && (
<p className="text-xs text-destructive">Failed to delete workspace.</p>
)}
</div>
<Separator />

View File

@@ -43,7 +43,7 @@ export function Sidebar() {
}
return (
<aside className="w-60 h-full border-r border-border bg-background flex flex-col">
<aside className="w-60 h-full min-h-0 border-r border-border bg-background flex flex-col">
{/* Top bar: Company name (bold) + Search — aligned with top sections (no visible border) */}
<div className="flex items-center gap-1 px-3 h-12 shrink-0">
<span className="flex-1 text-sm font-bold text-foreground truncate pl-1">

View File

@@ -117,8 +117,13 @@
* {
@apply border-border;
}
html {
height: 100%;
}
body {
@apply bg-background text-foreground;
height: 100%;
overflow: hidden;
}
/* Prevent double-tap-to-zoom on interactive elements for mobile */
a,

View File

@@ -5,7 +5,7 @@ import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys";
import { EmptyState } from "../components/EmptyState";
import { formatCents } from "../lib/utils";
import { formatCents, formatTokens } from "../lib/utils";
import { Identity } from "../components/Identity";
import { StatusBadge } from "../components/StatusBadge";
import { Card, CardContent } from "@/components/ui/card";
@@ -177,7 +177,7 @@ export function Costs() {
{data.byAgent.map((row) => (
<div
key={row.agentId}
className="flex items-center justify-between text-sm"
className="flex items-start justify-between text-sm"
>
<div className="flex items-center gap-2 min-w-0">
<Identity
@@ -188,9 +188,21 @@ export function Costs() {
<StatusBadge status="terminated" />
)}
</div>
<span className="font-medium shrink-0 ml-2">
{formatCents(row.costCents)}
</span>
<div className="text-right shrink-0 ml-2">
<span className="font-medium block">{formatCents(row.costCents)}</span>
<span className="text-xs text-muted-foreground block">
in {formatTokens(row.inputTokens)} / out {formatTokens(row.outputTokens)} tok
</span>
{(row.apiRunCount > 0 || row.subscriptionRunCount > 0) && (
<span className="text-xs text-muted-foreground block">
{row.apiRunCount > 0 ? `api runs: ${row.apiRunCount}` : null}
{row.apiRunCount > 0 && row.subscriptionRunCount > 0 ? " | " : null}
{row.subscriptionRunCount > 0
? `subscription runs: ${row.subscriptionRunCount} (${formatTokens(row.subscriptionInputTokens)} in / ${formatTokens(row.subscriptionOutputTokens)} out tok)`
: null}
</span>
)}
</div>
</div>
))}
</div>

View File

@@ -326,12 +326,12 @@ export function Inbox() {
showStaleSection ? "stale_work" : null,
].filter((key): key is SectionKey => key !== null);
const isLoading =
isJoinRequestsLoading ||
isApprovalsLoading ||
isDashboardLoading ||
isIssuesLoading ||
isRunsLoading;
const allLoaded =
!isJoinRequestsLoading &&
!isApprovalsLoading &&
!isDashboardLoading &&
!isIssuesLoading &&
!isRunsLoading;
const showSeparatorBefore = (key: SectionKey) => visibleSections.indexOf(key) > 0;
@@ -397,11 +397,14 @@ export function Inbox() {
)}
</div>
{isLoading && <p className="text-sm text-muted-foreground">Loading...</p>}
{approvalsError && <p className="text-sm text-destructive">{approvalsError.message}</p>}
{actionError && <p className="text-sm text-destructive">{actionError}</p>}
{!isLoading && visibleSections.length === 0 && (
{!allLoaded && visibleSections.length === 0 && (
<p className="text-sm text-muted-foreground">Loading...</p>
)}
{allLoaded && visibleSections.length === 0 && (
<EmptyState
icon={InboxIcon}
message={tab === "new" ? "You're all caught up!" : "No inbox items match these filters."}

View File

@@ -813,7 +813,7 @@ export function IssueDetail() {
{/* Mobile properties drawer */}
<Sheet open={mobilePropsOpen} onOpenChange={setMobilePropsOpen}>
<SheetContent side="bottom" className="max-h-[85vh]">
<SheetContent side="bottom" className="max-h-[85dvh] pb-[env(safe-area-inset-bottom)]">
<SheetHeader>
<SheetTitle className="text-sm">Properties</SheetTitle>
</SheetHeader>