1
.github/workflows/pr-policy.yml
vendored
1
.github/workflows/pr-policy.yml
vendored
@@ -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
|
||||
|
||||
43
.github/workflows/refresh-lockfile.yml
vendored
43
.github/workflows/refresh-lockfile.yml
vendored
@@ -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
|
||||
|
||||
@@ -42,6 +42,7 @@ function writeBaseConfig(configPath: string) {
|
||||
},
|
||||
auth: {
|
||||
baseUrlMode: "auto",
|
||||
disableSignUp: false,
|
||||
},
|
||||
storage: {
|
||||
provider: "local_disk",
|
||||
|
||||
@@ -61,6 +61,7 @@ function defaultConfig(): PaperclipConfig {
|
||||
},
|
||||
auth: {
|
||||
baseUrlMode: "auto",
|
||||
disableSignUp: false,
|
||||
},
|
||||
storage: defaultStorageConfig(),
|
||||
secrets: defaultSecretsConfig(),
|
||||
|
||||
@@ -185,6 +185,7 @@ function quickstartDefaultsFromEnv(): {
|
||||
},
|
||||
auth: {
|
||||
baseUrlMode: authBaseUrlMode,
|
||||
disableSignUp: false,
|
||||
...(authPublicBaseUrl ? { publicBaseUrl: authPublicBaseUrl } : {}),
|
||||
},
|
||||
storage: {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
21
scripts/prepare-server-ui-dist.sh
Executable file
21
scripts/prepare-server-ui-dist.sh
Executable 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"
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">·</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
40
ui/src/components/ScrollToBottom.tsx
Normal file
40
ui/src/components/ScrollToBottom.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">·</span>
|
||||
<span className="text-xs text-muted-foreground shrink-0 sm:order-last">
|
||||
{timeAgo(issue.updatedAt)}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@@ -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">·</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">
|
||||
·
|
||||
</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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user