diff --git a/.github/workflows/pr-policy.yml b/.github/workflows/pr-policy.yml index eb515eda..16953380 100644 --- a/.github/workflows/pr-policy.yml +++ b/.github/workflows/pr-policy.yml @@ -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 diff --git a/.github/workflows/refresh-lockfile.yml b/.github/workflows/refresh-lockfile.yml index 079fdd4e..a879e5bc 100644 --- a/.github/workflows/refresh-lockfile.yml +++ b/.github/workflows/refresh-lockfile.yml @@ -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 diff --git a/cli/src/__tests__/allowed-hostname.test.ts b/cli/src/__tests__/allowed-hostname.test.ts index 92dfbf42..572689c4 100644 --- a/cli/src/__tests__/allowed-hostname.test.ts +++ b/cli/src/__tests__/allowed-hostname.test.ts @@ -42,6 +42,7 @@ function writeBaseConfig(configPath: string) { }, auth: { baseUrlMode: "auto", + disableSignUp: false, }, storage: { provider: "local_disk", diff --git a/cli/src/commands/configure.ts b/cli/src/commands/configure.ts index d072fee9..969ead97 100644 --- a/cli/src/commands/configure.ts +++ b/cli/src/commands/configure.ts @@ -61,6 +61,7 @@ function defaultConfig(): PaperclipConfig { }, auth: { baseUrlMode: "auto", + disableSignUp: false, }, storage: defaultStorageConfig(), secrets: defaultSecretsConfig(), diff --git a/cli/src/commands/onboard.ts b/cli/src/commands/onboard.ts index 0e70d9cf..e3f17001 100644 --- a/cli/src/commands/onboard.ts +++ b/cli/src/commands/onboard.ts @@ -185,6 +185,7 @@ function quickstartDefaultsFromEnv(): { }, auth: { baseUrlMode: authBaseUrlMode, + disableSignUp: false, ...(authPublicBaseUrl ? { publicBaseUrl: authPublicBaseUrl } : {}), }, storage: { diff --git a/cli/src/prompts/server.ts b/cli/src/prompts/server.ts index c2ab4218..00611560 100644 --- a/cli/src/prompts/server.ts +++ b/cli/src/prompts/server.ts @@ -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, }; } diff --git a/scripts/prepare-server-ui-dist.sh b/scripts/prepare-server-ui-dist.sh new file mode 100755 index 00000000..d43807b3 --- /dev/null +++ b/scripts/prepare-server-ui-dist.sh @@ -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" diff --git a/scripts/release.sh b/scripts/release.sh index 6827e0fa..3668d87c 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -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 diff --git a/server/package.json b/server/package.json index 3e74286b..5c37c211 100644 --- a/server/package.json +++ b/server/package.json @@ -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" diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index 6335f02c..10d0709b 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -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({ - {/* Spacer matching caret width so status icon aligns with group title (hidden on mobile) */} -
-
{ e.preventDefault(); e.stopPropagation(); }}> + {/* Status icon - left column on mobile, inline on desktop */} + { e.preventDefault(); e.stopPropagation(); }}> onUpdateIssue(issue.id, { status: s })} /> -
- - {issue.identifier ?? issue.id.slice(0, 8)} - {issue.title} - {(issue.labels ?? []).length > 0 && ( -
- {(issue.labels ?? []).slice(0, 3).map((label) => ( - - {label.name} + + {/* Right column on mobile: title + metadata stacked */} + + {/* Title line */} + + {issue.title} + + + {/* Metadata line */} + + {/* Spacer matching caret width so status icon aligns with group title (hidden on mobile) */} + + + { e.preventDefault(); e.stopPropagation(); }}> + onUpdateIssue(issue.id, { status: s })} + /> + + + {issue.identifier ?? issue.id.slice(0, 8)} + + {liveIssueIds?.has(issue.id) && ( + + + + + + Live - ))} - {(issue.labels ?? []).length > 3 && ( - +{(issue.labels ?? []).length - 3} )} -
- )} -
- {liveIssueIds?.has(issue.id) && ( - - - - - - Live + · + + {timeAgo(issue.updatedAt)} + + + + + {/* Desktop-only trailing content */} + + {(issue.labels ?? []).length > 0 && ( + + {(issue.labels ?? []).slice(0, 3).map((label) => ( + + {label.name} + + ))} + {(issue.labels ?? []).length > 3 && ( + +{(issue.labels ?? []).length - 3} + )} )} -
- { - setAssigneePickerIssueId(open ? issue.id : null); - if (!open) setAssigneeSearch(""); - }} + { + setAssigneePickerIssueId(open ? issue.id : null); + if (!open) setAssigneeSearch(""); + }} + > + + + + e.stopPropagation()} + onPointerDownOutside={() => setAssigneeSearch("")} > - + setAssigneeSearch(e.target.value)} + autoFocus + /> +
- - e.stopPropagation()} - onPointerDownOutside={() => setAssigneeSearch("")} - > - setAssigneeSearch(e.target.value)} - autoFocus - /> -
- - {(agents ?? []) - .filter((agent) => { - if (!assigneeSearch.trim()) return true; - return agent.name.toLowerCase().includes(assigneeSearch.toLowerCase()); - }) - .map((agent) => ( - - ))} -
-
- -
- + {(agents ?? []) + .filter((agent) => { + if (!assigneeSearch.trim()) return true; + return agent.name.toLowerCase().includes(assigneeSearch.toLowerCase()); + }) + .map((agent) => ( + + ))} +
+ + + {formatDate(issue.createdAt)} -
+ ))} )) )} - {showScrollBottom && ( - - )}
); } diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index 99106f9f..4401821f 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -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; diff --git a/ui/src/components/ScrollToBottom.tsx b/ui/src/components/ScrollToBottom.tsx new file mode 100644 index 00000000..4ea8a494 --- /dev/null +++ b/ui/src/components/ScrollToBottom.tsx @@ -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 ( + + ); +} diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 06c3a2f4..596cb98d 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -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 */} + ); } diff --git a/ui/src/pages/Dashboard.tsx b/ui/src/pages/Dashboard.tsx index 823d57df..e1f9b9b0 100644 --- a/ui/src/pages/Dashboard.tsx +++ b/ui/src/pages/Dashboard.tsx @@ -313,26 +313,36 @@ export function Dashboard() { -
-
-
- - -
-

- {issue.title} +

+ {/* Status icon - left column on mobile */} + + + + + {/* Right column on mobile: title + metadata stacked */} + + + {issue.title} + + + + + + {issue.identifier ?? issue.id.slice(0, 8)} + {issue.assigneeAgentId && (() => { const name = agentName(issue.assigneeAgentId); return name - ? + ? : null; })()} -

-
- - {timeAgo(issue.updatedAt)} + · + + {timeAgo(issue.updatedAt)} + +
diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index 25d1da06..990f30ca 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -841,38 +841,44 @@ export function Inbox() { {staleIssues.map((issue) => (
+ {/* Status icon - left column on mobile; Clock icon on desktop */} + + + + + - - - - - {issue.identifier ?? issue.id.slice(0, 8)} + + {issue.title} - {issue.title} - {issue.assigneeAgentId && - (() => { - const name = agentName(issue.assigneeAgentId); - return name ? ( - - ) : ( - - {issue.assigneeAgentId.slice(0, 8)} - - ); - })()} - - updated {timeAgo(issue.updatedAt)} + + + + + {issue.identifier ?? issue.id.slice(0, 8)} + + {issue.assigneeAgentId && + (() => { + const name = agentName(issue.assigneeAgentId); + return name ? ( + + ) : null; + })()} + · + + updated {timeAgo(issue.updatedAt)} + - )} - - - - - - {issue.identifier ?? issue.id.slice(0, 8)} + } + }} + className="shrink-0 self-center cursor-pointer sm:hidden" + aria-label="Mark as read" + > + - {issue.title} - - {issue.lastExternalCommentAt - ? `commented ${timeAgo(issue.lastExternalCommentAt)}` - : `updated ${timeAgo(issue.updatedAt)}`} - - -
+ )} + ); })}
diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 90c94888..a0266c16 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -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() { + ); }