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:
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
|
||||
@@ -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..."
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = ``;
|
||||
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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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."}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user