Merge pull request #412 from paperclipai/dotta

Dotta
This commit is contained in:
Dotta
2026-03-09 07:50:18 -05:00
committed by GitHub
16 changed files with 368 additions and 231 deletions

View File

@@ -32,6 +32,7 @@ jobs:
node-version: 20
- name: Block manual lockfile edits
if: github.head_ref != 'chore/refresh-lockfile'
run: |
changed="$(git diff --name-only "${{ github.event.pull_request.base.sha }}" "${{ github.event.pull_request.head.sha }}")"
if printf '%s\n' "$changed" | grep -qx 'pnpm-lock.yaml'; then

View File

@@ -11,11 +11,12 @@ concurrency:
cancel-in-progress: false
jobs:
refresh_and_verify:
refresh:
runs-on: ubuntu-latest
timeout-minutes: 25
timeout-minutes: 10
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout repository
@@ -40,6 +41,7 @@ jobs:
run: |
changed="$(git status --porcelain)"
if [ -z "$changed" ]; then
echo "Lockfile is already up to date."
exit 0
fi
if printf '%s\n' "$changed" | grep -Fvq ' pnpm-lock.yaml'; then
@@ -48,29 +50,32 @@ jobs:
exit 1
fi
- name: Commit refreshed lockfile
- name: Create or update pull request
env:
GH_TOKEN: ${{ github.token }}
run: |
if git diff --quiet -- pnpm-lock.yaml; then
echo "Lockfile unchanged, nothing to do."
exit 0
fi
BRANCH="chore/refresh-lockfile"
git config user.name "lockfile-bot"
git config user.email "lockfile-bot@users.noreply.github.com"
git checkout -B "$BRANCH"
git add pnpm-lock.yaml
git commit -m "chore(lockfile): refresh pnpm-lock.yaml"
git push || {
echo "Push failed because master moved during lockfile refresh."
echo "A later refresh run should recompute the lockfile from the newer master state."
exit 1
}
git push --force origin "$BRANCH"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Typecheck
run: pnpm -r typecheck
- name: Run tests
run: pnpm test:run
- name: Build
run: pnpm build
# Create PR if one doesn't already exist
existing=$(gh pr list --head "$BRANCH" --json number --jq '.[0].number')
if [ -z "$existing" ]; then
gh pr create \
--head "$BRANCH" \
--title "chore(lockfile): refresh pnpm-lock.yaml" \
--body "Auto-generated lockfile refresh after dependencies changed on master. This PR only updates pnpm-lock.yaml."
echo "Created new PR."
else
echo "PR #$existing already exists, branch updated via force push."
fi

View File

@@ -42,6 +42,7 @@ function writeBaseConfig(configPath: string) {
},
auth: {
baseUrlMode: "auto",
disableSignUp: false,
},
storage: {
provider: "local_disk",

View File

@@ -61,6 +61,7 @@ function defaultConfig(): PaperclipConfig {
},
auth: {
baseUrlMode: "auto",
disableSignUp: false,
},
storage: defaultStorageConfig(),
secrets: defaultSecretsConfig(),

View File

@@ -185,6 +185,7 @@ function quickstartDefaultsFromEnv(): {
},
auth: {
baseUrlMode: authBaseUrlMode,
disableSignUp: false,
...(authPublicBaseUrl ? { publicBaseUrl: authPublicBaseUrl } : {}),
},
storage: {

View File

@@ -113,7 +113,7 @@ export async function promptServer(opts?: {
}
const port = Number(portStr) || 3100;
let auth: AuthConfig = { baseUrlMode: "auto" };
let auth: AuthConfig = { baseUrlMode: "auto", disableSignUp: false };
if (deploymentMode === "authenticated" && exposure === "public") {
const urlInput = await p.text({
message: "Public base URL",
@@ -139,11 +139,13 @@ export async function promptServer(opts?: {
}
auth = {
baseUrlMode: "explicit",
disableSignUp: false,
publicBaseUrl: urlInput.trim().replace(/\/+$/, ""),
};
} else if (currentAuth?.baseUrlMode === "explicit" && currentAuth.publicBaseUrl) {
auth = {
baseUrlMode: "explicit",
disableSignUp: false,
publicBaseUrl: currentAuth.publicBaseUrl,
};
}

View File

@@ -0,0 +1,21 @@
#!/usr/bin/env bash
set -euo pipefail
# prepare-server-ui-dist.sh — Build the UI and copy it into server/ui-dist.
# This keeps @paperclipai/server publish artifacts self-contained for static UI serving.
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
UI_DIST="$REPO_ROOT/ui/dist"
SERVER_UI_DIST="$REPO_ROOT/server/ui-dist"
echo " -> Building @paperclipai/ui..."
pnpm --dir "$REPO_ROOT" --filter @paperclipai/ui build
if [ ! -f "$UI_DIST/index.html" ]; then
echo "Error: UI build output missing at $UI_DIST/index.html"
exit 1
fi
rm -rf "$SERVER_UI_DIST"
cp -r "$UI_DIST" "$SERVER_UI_DIST"
echo " -> Copied ui/dist to server/ui-dist"

View File

@@ -283,9 +283,7 @@ pnpm --filter @paperclipai/adapter-openclaw-gateway build
pnpm --filter @paperclipai/server build
# Build UI and bundle into server package for static serving
pnpm --filter @paperclipai/ui build
rm -rf "$REPO_ROOT/server/ui-dist"
cp -r "$REPO_ROOT/ui/dist" "$REPO_ROOT/server/ui-dist"
bash "$REPO_ROOT/scripts/prepare-server-ui-dist.sh"
# Bundle skills into packages that need them (adapters + server)
for pkg_dir in server packages/adapters/claude-local packages/adapters/codex-local; do

View File

@@ -24,7 +24,10 @@
"scripts": {
"dev": "tsx src/index.ts",
"dev:watch": "PAPERCLIP_MIGRATION_PROMPT=never tsx watch --ignore ../ui/node_modules --ignore ../ui/.vite --ignore ../ui/dist src/index.ts",
"prepare:ui-dist": "bash ../scripts/prepare-server-ui-dist.sh",
"build": "tsc",
"prepack": "pnpm run prepare:ui-dist",
"postpack": "rm -rf ui-dist",
"clean": "rm -rf dist",
"start": "node dist/index.js",
"typecheck": "tsc --noEmit"

View File

@@ -7,6 +7,7 @@ import { issuesApi } from "../api/issues";
import { queryKeys } from "../lib/queryKeys";
import { groupBy } from "../lib/groupBy";
import { formatDate, cn } from "../lib/utils";
import { timeAgo } from "../lib/timeAgo";
import { StatusIcon } from "./StatusIcon";
import { PriorityIcon } from "./PriorityIcon";
import { EmptyState } from "./EmptyState";
@@ -17,7 +18,7 @@ import { Input } from "@/components/ui/input";
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
import { Checkbox } from "@/components/ui/checkbox";
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible";
import { CircleDot, Plus, Filter, ArrowUpDown, Layers, Check, X, ChevronRight, List, Columns3, User, Search, ArrowDown } from "lucide-react";
import { CircleDot, Plus, Filter, ArrowUpDown, Layers, Check, X, ChevronRight, List, Columns3, User, Search } from "lucide-react";
import { KanbanBoard } from "./KanbanBoard";
import type { Issue } from "@paperclipai/shared";
@@ -233,24 +234,6 @@ export function IssuesList({
const activeFilterCount = countActiveFilters(viewState);
const [showScrollBottom, setShowScrollBottom] = useState(false);
useEffect(() => {
const el = document.getElementById("main-content");
if (!el) return;
const check = () => {
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
setShowScrollBottom(distanceFromBottom > 300);
};
check();
el.addEventListener("scroll", check, { passive: true });
return () => el.removeEventListener("scroll", check);
}, [filtered.length]);
const scrollToBottom = useCallback(() => {
const el = document.getElementById("main-content");
if (el) el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
}, []);
const groupedContent = useMemo(() => {
if (viewState.groupBy === "none") {
return [{ key: "__all", label: null as string | null, items: filtered }];
@@ -608,149 +591,163 @@ export function IssuesList({
<Link
key={issue.id}
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"
className="flex items-start gap-2 py-2.5 pl-2 pr-3 text-sm border-b border-border last:border-b-0 cursor-pointer hover:bg-accent/50 transition-colors no-underline text-inherit sm:items-center sm:py-2 sm:pl-1"
>
{/* 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(); }}>
{/* Status icon - left column on mobile, inline on desktop */}
<span className="shrink-0 pt-px sm:hidden" onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>
<StatusIcon
status={issue.status}
onChange={(s) => onUpdateIssue(issue.id, { status: s })}
/>
</div>
<span className="text-sm text-muted-foreground font-mono shrink-0">
{issue.identifier ?? issue.id.slice(0, 8)}
</span>
<span className="truncate flex-1 min-w-0">{issue.title}</span>
{(issue.labels ?? []).length > 0 && (
<div className="hidden md:flex items-center gap-1 max-w-[240px] overflow-hidden">
{(issue.labels ?? []).slice(0, 3).map((label) => (
<span
key={label.id}
className="inline-flex items-center rounded-full border px-1.5 py-0.5 text-[10px] font-medium"
style={{
borderColor: label.color,
color: label.color,
backgroundColor: `${label.color}1f`,
}}
>
{label.name}
{/* Right column on mobile: title + metadata stacked */}
<span className="flex min-w-0 flex-1 flex-col gap-1 sm:contents">
{/* Title line */}
<span className="line-clamp-2 text-sm sm:order-2 sm:flex-1 sm:min-w-0 sm:line-clamp-none sm:truncate">
{issue.title}
</span>
{/* Metadata line */}
<span className="flex items-center gap-2 sm:order-1 sm:shrink-0">
{/* Spacer matching caret width so status icon aligns with group title (hidden on mobile) */}
<span className="w-3.5 shrink-0 hidden sm:block" />
<span className="hidden sm:inline-flex"><PriorityIcon priority={issue.priority} /></span>
<span className="hidden shrink-0 sm:inline-flex" onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>
<StatusIcon
status={issue.status}
onChange={(s) => onUpdateIssue(issue.id, { status: s })}
/>
</span>
<span className="text-xs text-muted-foreground font-mono shrink-0">
{issue.identifier ?? issue.id.slice(0, 8)}
</span>
{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">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
</span>
<span className="text-[11px] font-medium text-blue-600 dark:text-blue-400 hidden sm:inline">Live</span>
</span>
))}
{(issue.labels ?? []).length > 3 && (
<span className="text-[10px] text-muted-foreground">+{(issue.labels ?? []).length - 3}</span>
)}
</div>
)}
<div className="flex items-center gap-2 sm:gap-3 shrink-0 ml-auto">
{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">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
</span>
<span className="text-[11px] font-medium text-blue-600 dark:text-blue-400 hidden sm:inline">Live</span>
<span className="text-xs text-muted-foreground sm:hidden">&middot;</span>
<span className="text-xs text-muted-foreground sm:hidden">
{timeAgo(issue.updatedAt)}
</span>
</span>
</span>
{/* Desktop-only trailing content */}
<span className="hidden sm:flex sm:order-3 items-center gap-2 sm:gap-3 shrink-0 ml-auto">
{(issue.labels ?? []).length > 0 && (
<span className="hidden md:flex items-center gap-1 max-w-[240px] overflow-hidden">
{(issue.labels ?? []).slice(0, 3).map((label) => (
<span
key={label.id}
className="inline-flex items-center rounded-full border px-1.5 py-0.5 text-[10px] font-medium"
style={{
borderColor: label.color,
color: label.color,
backgroundColor: `${label.color}1f`,
}}
>
{label.name}
</span>
))}
{(issue.labels ?? []).length > 3 && (
<span className="text-[10px] text-muted-foreground">+{(issue.labels ?? []).length - 3}</span>
)}
</span>
)}
<div className="hidden sm:block">
<Popover
open={assigneePickerIssueId === issue.id}
onOpenChange={(open) => {
setAssigneePickerIssueId(open ? issue.id : null);
if (!open) setAssigneeSearch("");
}}
<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("")}
>
<PopoverTrigger asChild>
<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="flex w-[180px] shrink-0 items-center rounded-md px-2 py-1 hover:bg-accent/50 transition-colors"
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);
}}
>
{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>
)}
No assignee
</button>
</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>
<span className="text-xs text-muted-foreground hidden sm:inline">
{(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>
<span className="text-xs text-muted-foreground">
{formatDate(issue.createdAt)}
</span>
</div>
</span>
</Link>
))}
</CollapsibleContent>
</Collapsible>
))
)}
{showScrollBottom && (
<button
onClick={scrollToBottom}
className="fixed bottom-6 right-6 z-40 flex h-9 w-9 items-center justify-center rounded-full border border-border bg-background shadow-md hover:bg-accent transition-colors"
aria-label="Scroll to bottom"
>
<ArrowDown className="h-4 w-4" />
</button>
)}
</div>
);
}

View File

@@ -831,7 +831,7 @@ export function NewIssueDialog() {
placeholder="Add description..."
bordered={false}
mentions={mentionOptions}
contentClassName={cn("text-sm text-muted-foreground", expanded ? "min-h-[220px]" : "min-h-[120px]")}
contentClassName={cn("text-sm text-muted-foreground pb-12", expanded ? "min-h-[220px]" : "min-h-[120px]")}
imageUploadHandler={async (file) => {
const asset = await uploadDescriptionImage.mutateAsync(file);
return asset.contentPath;

View File

@@ -0,0 +1,40 @@
import { useCallback, useEffect, useState } from "react";
import { ArrowDown } from "lucide-react";
/**
* Floating scroll-to-bottom button that appears when the user is far from the
* bottom of the `#main-content` scroll container. Hides when within 300px of
* the bottom. Positioned to avoid the mobile bottom nav.
*/
export function ScrollToBottom() {
const [visible, setVisible] = useState(false);
useEffect(() => {
const el = document.getElementById("main-content");
if (!el) return;
const check = () => {
const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
setVisible(distance > 300);
};
check();
el.addEventListener("scroll", check, { passive: true });
return () => el.removeEventListener("scroll", check);
}, []);
const scroll = useCallback(() => {
const el = document.getElementById("main-content");
if (el) el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
}, []);
if (!visible) return null;
return (
<button
onClick={scroll}
className="fixed bottom-[calc(1.5rem+5rem+env(safe-area-inset-bottom))] right-6 z-40 flex h-9 w-9 items-center justify-center rounded-full border border-border bg-background shadow-md hover:bg-accent transition-colors md:bottom-6"
aria-label="Scroll to bottom"
>
<ArrowDown className="h-4 w-4" />
</button>
);
}

View File

@@ -24,6 +24,7 @@ import { CopyText } from "../components/CopyText";
import { EntityRow } from "../components/EntityRow";
import { Identity } from "../components/Identity";
import { PageSkeleton } from "../components/PageSkeleton";
import { ScrollToBottom } from "../components/ScrollToBottom";
import { formatCents, formatDate, relativeTime, formatTokens } from "../lib/utils";
import { cn } from "../lib/utils";
import { Button } from "@/components/ui/button";
@@ -1747,6 +1748,7 @@ function RunDetail({ run, agentRouteId, adapterType }: { run: HeartbeatRun; agen
{/* Log viewer */}
<LogViewer run={run} adapterType={adapterType} />
<ScrollToBottom />
</div>
);
}

View File

@@ -313,26 +313,36 @@ export function Dashboard() {
<Link
key={issue.id}
to={`/issues/${issue.identifier ?? issue.id}`}
className="px-4 py-2 text-sm cursor-pointer hover:bg-accent/50 transition-colors no-underline text-inherit block"
className="px-4 py-3 text-sm cursor-pointer hover:bg-accent/50 transition-colors no-underline text-inherit block"
>
<div className="flex gap-3">
<div className="flex items-start gap-2 min-w-0 flex-1">
<div className="flex items-center gap-2 shrink-0 mt-0.5">
<PriorityIcon priority={issue.priority} />
<StatusIcon status={issue.status} />
</div>
<p className="min-w-0 flex-1 truncate">
<span>{issue.title}</span>
<div className="flex items-start gap-2 sm:items-center sm:gap-3">
{/* Status icon - left column on mobile */}
<span className="shrink-0 sm:hidden">
<StatusIcon status={issue.status} />
</span>
{/* Right column on mobile: title + metadata stacked */}
<span className="flex min-w-0 flex-1 flex-col gap-1 sm:contents">
<span className="line-clamp-2 text-sm sm:order-2 sm:flex-1 sm:min-w-0 sm:line-clamp-none sm:truncate">
{issue.title}
</span>
<span className="flex items-center gap-2 sm:order-1 sm:shrink-0">
<span className="hidden sm:inline-flex"><PriorityIcon priority={issue.priority} /></span>
<span className="hidden sm:inline-flex"><StatusIcon status={issue.status} /></span>
<span className="text-xs font-mono text-muted-foreground">
{issue.identifier ?? issue.id.slice(0, 8)}
</span>
{issue.assigneeAgentId && (() => {
const name = agentName(issue.assigneeAgentId);
return name
? <span className="hidden sm:inline"><Identity name={name} size="sm" className="ml-2 inline-flex" /></span>
? <span className="hidden sm:inline-flex"><Identity name={name} size="sm" /></span>
: null;
})()}
</p>
</div>
<span className="text-xs text-muted-foreground shrink-0 pt-0.5">
{timeAgo(issue.updatedAt)}
<span className="text-xs text-muted-foreground sm:hidden">&middot;</span>
<span className="text-xs text-muted-foreground shrink-0 sm:order-last">
{timeAgo(issue.updatedAt)}
</span>
</span>
</span>
</div>
</Link>

View File

@@ -841,38 +841,44 @@ export function Inbox() {
{staleIssues.map((issue) => (
<div
key={issue.id}
className="group/stale relative flex items-center gap-3 px-4 py-3 transition-colors hover:bg-accent/50"
className="group/stale relative flex items-start gap-2 overflow-hidden px-3 py-3 transition-colors hover:bg-accent/50 sm:items-center sm:gap-3 sm:px-4"
>
{/* Status icon - left column on mobile; Clock icon on desktop */}
<span className="shrink-0 sm:hidden">
<StatusIcon status={issue.status} />
</span>
<Clock className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground hidden sm:block sm:mt-0" />
<Link
to={`/issues/${issue.identifier ?? issue.id}`}
className="flex flex-1 cursor-pointer items-center gap-3 no-underline text-inherit"
className="flex min-w-0 flex-1 cursor-pointer flex-col gap-1 no-underline text-inherit sm:flex-row sm:items-center sm:gap-3"
>
<Clock className="h-4 w-4 shrink-0 text-muted-foreground" />
<PriorityIcon priority={issue.priority} />
<StatusIcon status={issue.status} />
<span className="text-xs font-mono text-muted-foreground">
{issue.identifier ?? issue.id.slice(0, 8)}
<span className="line-clamp-2 text-sm sm:order-2 sm:flex-1 sm:min-w-0 sm:line-clamp-none sm:truncate">
{issue.title}
</span>
<span className="flex-1 truncate text-sm">{issue.title}</span>
{issue.assigneeAgentId &&
(() => {
const name = agentName(issue.assigneeAgentId);
return name ? (
<Identity name={name} size="sm" />
) : (
<span className="font-mono text-xs text-muted-foreground">
{issue.assigneeAgentId.slice(0, 8)}
</span>
);
})()}
<span className="shrink-0 text-xs text-muted-foreground">
updated {timeAgo(issue.updatedAt)}
<span className="flex items-center gap-2 sm:order-1 sm:shrink-0">
<span className="hidden sm:inline-flex"><PriorityIcon priority={issue.priority} /></span>
<span className="hidden sm:inline-flex"><StatusIcon status={issue.status} /></span>
<span className="shrink-0 text-xs font-mono text-muted-foreground">
{issue.identifier ?? issue.id.slice(0, 8)}
</span>
{issue.assigneeAgentId &&
(() => {
const name = agentName(issue.assigneeAgentId);
return name ? (
<span className="hidden sm:inline-flex"><Identity name={name} size="sm" /></span>
) : null;
})()}
<span className="text-xs text-muted-foreground sm:hidden">&middot;</span>
<span className="shrink-0 text-xs text-muted-foreground sm:order-last">
updated {timeAgo(issue.updatedAt)}
</span>
</span>
</Link>
<button
type="button"
onClick={() => dismiss(`stale:${issue.id}`)}
className="rounded-md p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground group-hover/stale:opacity-100"
className="mt-0.5 rounded-md p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground group-hover/stale:opacity-100 sm:mt-0"
aria-label="Dismiss"
>
<X className="h-3.5 w-3.5" />
@@ -896,47 +902,94 @@ export function Inbox() {
const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id);
const isFading = fadingOutIssues.has(issue.id);
return (
<div
<Link
key={issue.id}
className="flex items-center gap-3 px-4 py-3 transition-colors hover:bg-accent/50"
to={`/issues/${issue.identifier ?? issue.id}`}
className="flex min-w-0 cursor-pointer items-start gap-2 px-3 py-3 no-underline text-inherit transition-colors hover:bg-accent/50 sm:items-center sm:gap-3 sm:px-4"
>
<span className="flex w-4 shrink-0 justify-center">
{(isUnread || isFading) && (
<button
type="button"
onClick={(e) => {
{/* Status icon - left column on mobile, inline on desktop */}
<span className="shrink-0 sm:hidden">
<StatusIcon status={issue.status} />
</span>
{/* Right column on mobile: title + metadata stacked */}
<span className="flex min-w-0 flex-1 flex-col gap-1 sm:contents">
<span className="line-clamp-2 text-sm sm:order-2 sm:flex-1 sm:min-w-0 sm:line-clamp-none sm:truncate">
{issue.title}
</span>
<span className="flex items-center gap-2 sm:order-1 sm:shrink-0">
{(isUnread || isFading) ? (
<span
role="button"
tabIndex={0}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
markReadMutation.mutate(issue.id);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
markReadMutation.mutate(issue.id);
}
}}
className="hidden sm:inline-flex h-4 w-4 shrink-0 cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-blue-500/20"
aria-label="Mark as read"
>
<span
className={`h-2 w-2 rounded-full bg-blue-600 dark:bg-blue-400 transition-opacity duration-300 ${
isFading ? "opacity-0" : "opacity-100"
}`}
/>
</span>
) : (
<span className="hidden sm:inline-flex h-4 w-4 shrink-0" />
)}
<span className="hidden sm:inline-flex"><PriorityIcon priority={issue.priority} /></span>
<span className="hidden sm:inline-flex"><StatusIcon status={issue.status} /></span>
<span className="text-xs font-mono text-muted-foreground">
{issue.identifier ?? issue.id.slice(0, 8)}
</span>
<span className="text-xs text-muted-foreground sm:hidden">
&middot;
</span>
<span className="text-xs text-muted-foreground sm:order-last">
{issue.lastExternalCommentAt
? `commented ${timeAgo(issue.lastExternalCommentAt)}`
: `updated ${timeAgo(issue.updatedAt)}`}
</span>
</span>
</span>
{/* Unread dot - right side, vertically centered (mobile only; desktop keeps inline) */}
{(isUnread || isFading) && (
<span
role="button"
tabIndex={0}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
markReadMutation.mutate(issue.id);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
markReadMutation.mutate(issue.id);
}}
className="group/dot flex h-4 w-4 items-center justify-center rounded-full transition-colors hover:bg-blue-500/20"
aria-label="Mark as read"
>
<span
className={`h-2.5 w-2.5 rounded-full bg-blue-600 dark:bg-blue-400 transition-opacity duration-300 ${
isFading ? "opacity-0" : "opacity-100"
}`}
/>
</button>
)}
</span>
<Link
to={`/issues/${issue.identifier ?? issue.id}`}
className="flex flex-1 min-w-0 cursor-pointer items-center gap-3 no-underline text-inherit"
>
<PriorityIcon priority={issue.priority} />
<StatusIcon status={issue.status} />
<span className="text-xs font-mono text-muted-foreground">
{issue.identifier ?? issue.id.slice(0, 8)}
}
}}
className="shrink-0 self-center cursor-pointer sm:hidden"
aria-label="Mark as read"
>
<span
className={`block h-2 w-2 rounded-full bg-blue-600 dark:bg-blue-400 transition-opacity duration-300 ${
isFading ? "opacity-0" : "opacity-100"
}`}
/>
</span>
<span className="flex-1 truncate text-sm">{issue.title}</span>
<span className="shrink-0 text-xs text-muted-foreground">
{issue.lastExternalCommentAt
? `commented ${timeAgo(issue.lastExternalCommentAt)}`
: `updated ${timeAgo(issue.updatedAt)}`}
</span>
</Link>
</div>
)}
</Link>
);
})}
</div>

View File

@@ -18,6 +18,7 @@ import { CommentThread } from "../components/CommentThread";
import { IssueProperties } from "../components/IssueProperties";
import { LiveRunWidget } from "../components/LiveRunWidget";
import type { MentionOption } from "../components/MarkdownEditor";
import { ScrollToBottom } from "../components/ScrollToBottom";
import { StatusIcon } from "../components/StatusIcon";
import { PriorityIcon } from "../components/PriorityIcon";
import { StatusBadge } from "../components/StatusBadge";
@@ -926,6 +927,7 @@ export function IssueDetail() {
</ScrollArea>
</SheetContent>
</Sheet>
<ScrollToBottom />
</div>
);
}