feat(ui): reconcile backup UI changes with current routing and interaction features
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState, type MutableRefObject } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Link } from "@/lib/router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { Issue, LiveEvent } from "@paperclip/shared";
|
||||
import { heartbeatsApi, type LiveRunForIssue } from "../api/heartbeats";
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { Link } from "@/lib/router";
|
||||
import { Identity } from "./Identity";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
import { cn } from "../lib/utils";
|
||||
import type { ActivityEvent } from "@paperclip/shared";
|
||||
import type { Agent } from "@paperclip/shared";
|
||||
import { deriveProjectUrlKey, type ActivityEvent, type Agent } from "@paperclip/shared";
|
||||
|
||||
const ACTION_VERBS: Record<string, string> = {
|
||||
"issue.created": "created",
|
||||
@@ -70,7 +69,7 @@ function entityLink(entityType: string, entityId: string, name?: string | null):
|
||||
switch (entityType) {
|
||||
case "issue": return `/issues/${name ?? entityId}`;
|
||||
case "agent": return `/agents/${entityId}`;
|
||||
case "project": return `/projects/${entityId}`;
|
||||
case "project": return `/projects/${deriveProjectUrlKey(name, entityId)}`;
|
||||
case "goal": return `/goals/${entityId}`;
|
||||
case "approval": return `/approvals/${entityId}`;
|
||||
default: return null;
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Link } from "@/lib/router";
|
||||
import type { Agent, AgentRuntimeState } from "@paperclip/shared";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { StatusBadge } from "./StatusBadge";
|
||||
import { Identity } from "./Identity";
|
||||
import { formatDate } from "../lib/utils";
|
||||
import { formatDate, agentUrl } from "../lib/utils";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
interface AgentPropertiesProps {
|
||||
@@ -84,7 +84,7 @@ export function AgentProperties({ agent, runtimeState }: AgentPropertiesProps) {
|
||||
{agent.reportsTo && (
|
||||
<PropertyRow label="Reports To">
|
||||
{reportsToAgent ? (
|
||||
<Link to={`/agents/${reportsToAgent.id}`} className="hover:underline">
|
||||
<Link to={agentUrl(reportsToAgent)} className="hover:underline">
|
||||
<Identity name={reportsToAgent.name} size="sm" />
|
||||
</Link>
|
||||
) : (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CheckCircle2, XCircle, Clock } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Link } from "@/lib/router";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Identity } from "./Identity";
|
||||
import { typeLabel, typeIcon, defaultTypeIcon, ApprovalPayloadRenderer } from "./ApprovalPayload";
|
||||
|
||||
@@ -1,16 +1,45 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
const CHARS = "░▒▓█▄▀■□▪▫●○◆◇◈◉★☆✦✧·.";
|
||||
const CHARS = [" ", ".", "·", "▪", "▫", "○"] as const;
|
||||
const TARGET_FPS = 24;
|
||||
const FRAME_INTERVAL_MS = 1000 / TARGET_FPS;
|
||||
|
||||
interface Particle {
|
||||
const PAPERCLIP_SPRITES = [
|
||||
[
|
||||
" ╭────╮ ",
|
||||
" ╭╯╭──╮│ ",
|
||||
" │ │ ││ ",
|
||||
" │ │ ││ ",
|
||||
" │ │ ││ ",
|
||||
" │ │ ││ ",
|
||||
" │ ╰──╯│ ",
|
||||
" ╰─────╯ ",
|
||||
],
|
||||
[
|
||||
" ╭─────╮ ",
|
||||
" │╭──╮╰╮ ",
|
||||
" ││ │ │ ",
|
||||
" ││ │ │ ",
|
||||
" ││ │ │ ",
|
||||
" ││ │ │ ",
|
||||
" │╰──╯ │ ",
|
||||
" ╰────╯ ",
|
||||
],
|
||||
] as const;
|
||||
|
||||
type PaperclipSprite = (typeof PAPERCLIP_SPRITES)[number];
|
||||
|
||||
interface Clip {
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
char: string;
|
||||
life: number;
|
||||
maxLife: number;
|
||||
phase: number;
|
||||
drift: number;
|
||||
sprite: PaperclipSprite;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
function measureChar(container: HTMLElement): { w: number; h: number } {
|
||||
@@ -24,167 +53,287 @@ function measureChar(container: HTMLElement): { w: number; h: number } {
|
||||
return { w: rect.width, h: rect.height };
|
||||
}
|
||||
|
||||
function spriteSize(sprite: PaperclipSprite): { width: number; height: number } {
|
||||
let width = 0;
|
||||
for (const row of sprite) width = Math.max(width, row.length);
|
||||
return { width, height: sprite.length };
|
||||
}
|
||||
|
||||
export function AsciiArtAnimation() {
|
||||
const preRef = useRef<HTMLPreElement>(null);
|
||||
const frameRef = useRef(0);
|
||||
const particlesRef = useRef<Particle[]>([]);
|
||||
const frameRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!preRef.current) return;
|
||||
const preEl: HTMLPreElement = preRef.current;
|
||||
const motionMedia = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
let isVisible = document.visibilityState !== "hidden";
|
||||
let loopActive = false;
|
||||
let lastRenderAt = 0;
|
||||
let tick = 0;
|
||||
let cols = 0;
|
||||
let rows = 0;
|
||||
let charW = 7;
|
||||
let charH = 11;
|
||||
let trail = new Float32Array(0);
|
||||
let colWave = new Float32Array(0);
|
||||
let rowWave = new Float32Array(0);
|
||||
let clipMask = new Uint16Array(0);
|
||||
let clips: Clip[] = [];
|
||||
let lastOutput = "";
|
||||
|
||||
const charSize = measureChar(preEl);
|
||||
let charW = charSize.w;
|
||||
let charH = charSize.h;
|
||||
let cols = Math.ceil(preEl.clientWidth / charW);
|
||||
let rows = Math.ceil(preEl.clientHeight / charH);
|
||||
let particles = particlesRef.current;
|
||||
function toGlyph(value: number): string {
|
||||
const clamped = Math.max(0, Math.min(0.999, value));
|
||||
const idx = Math.floor(clamped * CHARS.length);
|
||||
return CHARS[idx] ?? " ";
|
||||
}
|
||||
|
||||
function spawnParticle() {
|
||||
const edge = Math.random();
|
||||
let x: number, y: number, vx: number, vy: number;
|
||||
if (edge < 0.5) {
|
||||
x = -1;
|
||||
y = Math.random() * rows;
|
||||
vx = 0.3 + Math.random() * 0.5;
|
||||
vy = (Math.random() - 0.5) * 0.2;
|
||||
} else {
|
||||
x = Math.random() * cols;
|
||||
y = rows + 1;
|
||||
vx = (Math.random() - 0.5) * 0.2;
|
||||
vy = -(0.2 + Math.random() * 0.4);
|
||||
function rebuildGrid() {
|
||||
const nextCols = Math.max(0, Math.ceil(preEl.clientWidth / Math.max(1, charW)));
|
||||
const nextRows = Math.max(0, Math.ceil(preEl.clientHeight / Math.max(1, charH)));
|
||||
if (nextCols === cols && nextRows === rows) return;
|
||||
|
||||
cols = nextCols;
|
||||
rows = nextRows;
|
||||
const cellCount = cols * rows;
|
||||
trail = new Float32Array(cellCount);
|
||||
colWave = new Float32Array(cols);
|
||||
rowWave = new Float32Array(rows);
|
||||
clipMask = new Uint16Array(cellCount);
|
||||
clips = clips.filter((clip) => {
|
||||
return (
|
||||
clip.x > -clip.width - 2 &&
|
||||
clip.x < cols + 2 &&
|
||||
clip.y > -clip.height - 2 &&
|
||||
clip.y < rows + 2
|
||||
);
|
||||
});
|
||||
lastOutput = "";
|
||||
}
|
||||
|
||||
function drawStaticFrame() {
|
||||
if (cols <= 0 || rows <= 0) {
|
||||
preEl.textContent = "";
|
||||
return;
|
||||
}
|
||||
const maxLife = 60 + Math.random() * 120;
|
||||
particles.push({
|
||||
x, y, vx, vy,
|
||||
char: CHARS[Math.floor(Math.random() * CHARS.length)],
|
||||
|
||||
const grid = Array.from({ length: rows }, () => Array.from({ length: cols }, () => " "));
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const ambient = (Math.sin(c * 0.11 + r * 0.04) + Math.cos(r * 0.08 - c * 0.02)) * 0.18 + 0.22;
|
||||
grid[r][c] = toGlyph(ambient);
|
||||
}
|
||||
}
|
||||
|
||||
const gapX = 18;
|
||||
const gapY = 13;
|
||||
for (let baseRow = 1; baseRow < rows - 9; baseRow += gapY) {
|
||||
const startX = Math.floor(baseRow / gapY) % 2 === 0 ? 2 : 10;
|
||||
for (let baseCol = startX; baseCol < cols - 10; baseCol += gapX) {
|
||||
const sprite = PAPERCLIP_SPRITES[(baseCol + baseRow) % PAPERCLIP_SPRITES.length]!;
|
||||
for (let sr = 0; sr < sprite.length; sr++) {
|
||||
const line = sprite[sr]!;
|
||||
for (let sc = 0; sc < line.length; sc++) {
|
||||
const ch = line[sc] ?? " ";
|
||||
if (ch === " ") continue;
|
||||
const row = baseRow + sr;
|
||||
const col = baseCol + sc;
|
||||
if (row < 0 || row >= rows || col < 0 || col >= cols) continue;
|
||||
grid[row]![col] = ch;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const output = grid.map((line) => line.join("")).join("\n");
|
||||
preEl.textContent = output;
|
||||
lastOutput = output;
|
||||
}
|
||||
|
||||
function spawnClip() {
|
||||
const sprite = PAPERCLIP_SPRITES[Math.floor(Math.random() * PAPERCLIP_SPRITES.length)]!;
|
||||
const size = spriteSize(sprite);
|
||||
const edge = Math.random();
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
let vx = 0;
|
||||
let vy = 0;
|
||||
|
||||
if (edge < 0.68) {
|
||||
x = Math.random() < 0.5 ? -size.width - 1 : cols + 1;
|
||||
y = Math.random() * Math.max(1, rows - size.height);
|
||||
vx = x < 0 ? 0.04 + Math.random() * 0.05 : -(0.04 + Math.random() * 0.05);
|
||||
vy = (Math.random() - 0.5) * 0.014;
|
||||
} else {
|
||||
x = Math.random() * Math.max(1, cols - size.width);
|
||||
y = Math.random() < 0.5 ? -size.height - 1 : rows + 1;
|
||||
vx = (Math.random() - 0.5) * 0.014;
|
||||
vy = y < 0 ? 0.028 + Math.random() * 0.034 : -(0.028 + Math.random() * 0.034);
|
||||
}
|
||||
|
||||
clips.push({
|
||||
x,
|
||||
y,
|
||||
vx,
|
||||
vy,
|
||||
life: 0,
|
||||
maxLife,
|
||||
phase: Math.random() * Math.PI * 2,
|
||||
maxLife: 260 + Math.random() * 220,
|
||||
drift: (Math.random() - 0.5) * 1.2,
|
||||
sprite,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
});
|
||||
}
|
||||
|
||||
function render(time: number) {
|
||||
const t = time * 0.001;
|
||||
|
||||
// Spawn particles
|
||||
const targetCount = Math.floor((cols * rows) / 12);
|
||||
while (particles.length < targetCount) {
|
||||
spawnParticle();
|
||||
}
|
||||
|
||||
// Build grid
|
||||
const grid: string[][] = Array.from({ length: rows }, () =>
|
||||
Array.from({ length: cols }, () => " ")
|
||||
);
|
||||
const opacity: number[][] = Array.from({ length: rows }, () =>
|
||||
Array.from({ length: cols }, () => 0)
|
||||
);
|
||||
|
||||
// Background wave pattern
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const wave =
|
||||
Math.sin(c * 0.08 + t * 0.7 + r * 0.04) *
|
||||
Math.sin(r * 0.06 - t * 0.5) *
|
||||
Math.cos((c + r) * 0.03 + t * 0.3);
|
||||
if (wave > 0.65) {
|
||||
grid[r][c] = wave > 0.85 ? "·" : ".";
|
||||
opacity[r][c] = Math.min(1, (wave - 0.65) * 3);
|
||||
}
|
||||
function stampClip(clip: Clip, alpha: number) {
|
||||
const baseCol = Math.round(clip.x);
|
||||
const baseRow = Math.round(clip.y);
|
||||
for (let sr = 0; sr < clip.sprite.length; sr++) {
|
||||
const line = clip.sprite[sr]!;
|
||||
const row = baseRow + sr;
|
||||
if (row < 0 || row >= rows) continue;
|
||||
for (let sc = 0; sc < line.length; sc++) {
|
||||
const ch = line[sc] ?? " ";
|
||||
if (ch === " ") continue;
|
||||
const col = baseCol + sc;
|
||||
if (col < 0 || col >= cols) continue;
|
||||
const idx = row * cols + col;
|
||||
const stroke = ch === "│" || ch === "─" ? 0.8 : 0.92;
|
||||
trail[idx] = Math.max(trail[idx] ?? 0, alpha * stroke);
|
||||
clipMask[idx] = ch.charCodeAt(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update and render particles
|
||||
for (let i = particles.length - 1; i >= 0; i--) {
|
||||
const p = particles[i];
|
||||
p.life++;
|
||||
function step(time: number) {
|
||||
if (!loopActive) return;
|
||||
frameRef.current = requestAnimationFrame(step);
|
||||
if (time - lastRenderAt < FRAME_INTERVAL_MS || cols <= 0 || rows <= 0) return;
|
||||
|
||||
// Flow field influence
|
||||
const angle =
|
||||
Math.sin(p.x * 0.05 + t * 0.3) * Math.cos(p.y * 0.07 - t * 0.2) *
|
||||
Math.PI;
|
||||
p.vx += Math.cos(angle) * 0.02;
|
||||
p.vy += Math.sin(angle) * 0.02;
|
||||
const delta = Math.min(2, lastRenderAt === 0 ? 1 : (time - lastRenderAt) / 16.6667);
|
||||
lastRenderAt = time;
|
||||
tick += delta;
|
||||
|
||||
// Damping
|
||||
p.vx *= 0.98;
|
||||
p.vy *= 0.98;
|
||||
const cellCount = cols * rows;
|
||||
const targetCount = Math.max(3, Math.floor(cellCount / 2200));
|
||||
while (clips.length < targetCount) spawnClip();
|
||||
|
||||
p.x += p.vx;
|
||||
p.y += p.vy;
|
||||
for (let i = 0; i < trail.length; i++) trail[i] *= 0.92;
|
||||
clipMask.fill(0);
|
||||
|
||||
// Life fade
|
||||
const lifeFrac = p.life / p.maxLife;
|
||||
const alpha = lifeFrac < 0.1
|
||||
? lifeFrac / 0.1
|
||||
: lifeFrac > 0.8
|
||||
? (1 - lifeFrac) / 0.2
|
||||
: 1;
|
||||
for (let i = clips.length - 1; i >= 0; i--) {
|
||||
const clip = clips[i]!;
|
||||
clip.life += delta;
|
||||
|
||||
const wobbleX = Math.sin((clip.y + clip.drift + tick * 0.12) * 0.09) * 0.0018;
|
||||
const wobbleY = Math.cos((clip.x - clip.drift - tick * 0.09) * 0.08) * 0.0014;
|
||||
clip.vx = (clip.vx + wobbleX) * 0.998;
|
||||
clip.vy = (clip.vy + wobbleY) * 0.998;
|
||||
|
||||
clip.x += clip.vx * delta;
|
||||
clip.y += clip.vy * delta;
|
||||
|
||||
// Remove dead or out-of-bounds particles
|
||||
if (
|
||||
p.life >= p.maxLife ||
|
||||
p.x < -2 || p.x > cols + 2 ||
|
||||
p.y < -2 || p.y > rows + 2
|
||||
clip.life >= clip.maxLife ||
|
||||
clip.x < -clip.width - 2 ||
|
||||
clip.x > cols + 2 ||
|
||||
clip.y < -clip.height - 2 ||
|
||||
clip.y > rows + 2
|
||||
) {
|
||||
particles.splice(i, 1);
|
||||
clips.splice(i, 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
const col = Math.round(p.x);
|
||||
const row = Math.round(p.y);
|
||||
if (row >= 0 && row < rows && col >= 0 && col < cols) {
|
||||
if (alpha > opacity[row][col]) {
|
||||
// Cycle through characters based on life
|
||||
const charIdx = Math.floor(
|
||||
(lifeFrac + Math.sin(p.phase + t)) * CHARS.length
|
||||
) % CHARS.length;
|
||||
grid[row][col] = CHARS[Math.abs(charIdx)];
|
||||
opacity[row][col] = alpha;
|
||||
}
|
||||
}
|
||||
const life = clip.life / clip.maxLife;
|
||||
const alpha = life < 0.12 ? life / 0.12 : life > 0.88 ? (1 - life) / 0.12 : 1;
|
||||
stampClip(clip, alpha);
|
||||
}
|
||||
|
||||
// Render to string
|
||||
for (let c = 0; c < cols; c++) colWave[c] = Math.sin(c * 0.08 + tick * 0.06);
|
||||
for (let r = 0; r < rows; r++) rowWave[r] = Math.cos(r * 0.1 - tick * 0.05);
|
||||
|
||||
let output = "";
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const a = opacity[r][c];
|
||||
if (a > 0 && grid[r][c] !== " ") {
|
||||
const o = Math.round(a * 60 + 40);
|
||||
output += `<span style="opacity:${o}%">${grid[r][c]}</span>`;
|
||||
} else {
|
||||
output += " ";
|
||||
const idx = r * cols + c;
|
||||
const clipChar = clipMask[idx];
|
||||
if (clipChar > 0) {
|
||||
output += String.fromCharCode(clipChar);
|
||||
continue;
|
||||
}
|
||||
const ambient = (colWave[c] + rowWave[r]) * 0.08 + 0.1;
|
||||
const intensity = Math.max(trail[idx] ?? 0, ambient * 0.45);
|
||||
output += toGlyph(intensity);
|
||||
}
|
||||
if (r < rows - 1) output += "\n";
|
||||
}
|
||||
|
||||
preEl.innerHTML = output;
|
||||
frameRef.current = requestAnimationFrame(render);
|
||||
if (output !== lastOutput) {
|
||||
preEl.textContent = output;
|
||||
lastOutput = output;
|
||||
}
|
||||
}
|
||||
|
||||
function syncLoop() {
|
||||
const canRender = cols > 0 && rows > 0;
|
||||
if (motionMedia.matches) {
|
||||
if (loopActive) {
|
||||
loopActive = false;
|
||||
if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);
|
||||
frameRef.current = null;
|
||||
}
|
||||
if (canRender) drawStaticFrame();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isVisible || !canRender) {
|
||||
if (loopActive) {
|
||||
loopActive = false;
|
||||
if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);
|
||||
frameRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!loopActive) {
|
||||
loopActive = true;
|
||||
lastRenderAt = 0;
|
||||
frameRef.current = requestAnimationFrame(step);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle resize
|
||||
const observer = new ResizeObserver(() => {
|
||||
const size = measureChar(preEl);
|
||||
charW = size.w;
|
||||
charH = size.h;
|
||||
cols = Math.ceil(preEl.clientWidth / charW);
|
||||
rows = Math.ceil(preEl.clientHeight / charH);
|
||||
// Cull out-of-bounds particles on resize
|
||||
particles = particles.filter(
|
||||
(p) => p.x >= -2 && p.x <= cols + 2 && p.y >= -2 && p.y <= rows + 2
|
||||
);
|
||||
particlesRef.current = particles;
|
||||
rebuildGrid();
|
||||
syncLoop();
|
||||
});
|
||||
observer.observe(preEl);
|
||||
|
||||
frameRef.current = requestAnimationFrame(render);
|
||||
const onVisibilityChange = () => {
|
||||
isVisible = document.visibilityState !== "hidden";
|
||||
syncLoop();
|
||||
};
|
||||
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||
|
||||
const onMotionChange = () => {
|
||||
syncLoop();
|
||||
};
|
||||
motionMedia.addEventListener("change", onMotionChange);
|
||||
|
||||
const charSize = measureChar(preEl);
|
||||
charW = charSize.w;
|
||||
charH = charSize.h;
|
||||
rebuildGrid();
|
||||
syncLoop();
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(frameRef.current);
|
||||
loopActive = false;
|
||||
if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);
|
||||
observer.disconnect();
|
||||
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||
motionMedia.removeEventListener("change", onMotionChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { Link } from "@/lib/router";
|
||||
import { Menu } from "lucide-react";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
@@ -25,6 +25,7 @@ export function BreadcrumbBar() {
|
||||
size="icon-sm"
|
||||
className="mr-2 shrink-0"
|
||||
onClick={toggleSidebar}
|
||||
aria-label="Open sidebar"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</Button>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useNavigate } from "@/lib/router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
Plus,
|
||||
} from "lucide-react";
|
||||
import { Identity } from "./Identity";
|
||||
import { agentUrl, projectUrl } from "../lib/utils";
|
||||
|
||||
export function CommandPalette() {
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -174,10 +175,9 @@ export function CommandPalette() {
|
||||
key={issue.id}
|
||||
value={
|
||||
searchQuery.length > 0
|
||||
? `${searchQuery} ${issue.identifier ?? ""} ${issue.title} ${issue.description ?? ""}`
|
||||
? `${searchQuery} ${issue.identifier ?? ""} ${issue.title}`
|
||||
: undefined
|
||||
}
|
||||
keywords={issue.description ? [issue.description] : undefined}
|
||||
onSelect={() => go(`/issues/${issue.identifier ?? issue.id}`)}
|
||||
>
|
||||
<CircleDot className="mr-2 h-4 w-4" />
|
||||
@@ -200,7 +200,7 @@ export function CommandPalette() {
|
||||
<CommandSeparator />
|
||||
<CommandGroup heading="Agents">
|
||||
{agents.slice(0, 10).map((agent) => (
|
||||
<CommandItem key={agent.id} onSelect={() => go(`/agents/${agent.id}`)}>
|
||||
<CommandItem key={agent.id} onSelect={() => go(agentUrl(agent))}>
|
||||
<Bot className="mr-2 h-4 w-4" />
|
||||
{agent.name}
|
||||
<span className="text-xs text-muted-foreground ml-2">{agent.role}</span>
|
||||
@@ -215,7 +215,7 @@ export function CommandPalette() {
|
||||
<CommandSeparator />
|
||||
<CommandGroup heading="Projects">
|
||||
{projects.slice(0, 10).map((project) => (
|
||||
<CommandItem key={project.id} onSelect={() => go(`/projects/${project.id}`)}>
|
||||
<CommandItem key={project.id} onSelect={() => go(projectUrl(project))}>
|
||||
<Hexagon className="mr-2 h-4 w-4" />
|
||||
{project.name}
|
||||
</CommandItem>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Paperclip, Plus } from "lucide-react";
|
||||
import { useQueries } from "@tanstack/react-query";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
@@ -18,6 +19,9 @@ import { CSS } from "@dnd-kit/utilities";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { cn } from "../lib/utils";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { sidebarBadgesApi } from "../api/sidebarBadges";
|
||||
import { heartbeatsApi } from "../api/heartbeats";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -65,10 +69,14 @@ function sortByStoredOrder(companies: Company[]): Company[] {
|
||||
function SortableCompanyItem({
|
||||
company,
|
||||
isSelected,
|
||||
hasLiveAgents,
|
||||
hasUnreadInbox,
|
||||
onSelect,
|
||||
}: {
|
||||
company: Company;
|
||||
isSelected: boolean;
|
||||
hasLiveAgents: boolean;
|
||||
hasUnreadInbox: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const {
|
||||
@@ -88,28 +96,28 @@ function SortableCompanyItem({
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
|
||||
<div ref={setNodeRef} style={style} {...attributes} {...listeners} className="overflow-visible">
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<a
|
||||
href={`/dashboard?company=${company.id}`}
|
||||
href={`/${company.issuePrefix}/dashboard`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onSelect();
|
||||
}}
|
||||
className="relative flex items-center justify-center group"
|
||||
className="relative flex items-center justify-center group overflow-visible"
|
||||
>
|
||||
{/* Selection indicator pill */}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute left-[-14px] w-1 rounded-r-full bg-foreground transition-all duration-200",
|
||||
"absolute left-[-14px] w-1 rounded-r-full bg-foreground transition-[height] duration-150",
|
||||
isSelected
|
||||
? "h-5"
|
||||
: "h-0 group-hover:h-2"
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn("transition-all duration-200", isDragging && "scale-105")}
|
||||
className={cn("relative overflow-visible transition-transform duration-150", isDragging && "scale-105")}
|
||||
>
|
||||
<CompanyPatternIcon
|
||||
companyName={company.name}
|
||||
@@ -121,6 +129,17 @@ function SortableCompanyItem({
|
||||
isDragging && "shadow-lg",
|
||||
)}
|
||||
/>
|
||||
{hasLiveAgents && (
|
||||
<span className="pointer-events-none absolute -right-0.5 -top-0.5 z-10">
|
||||
<span className="relative flex h-2.5 w-2.5">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-blue-400 opacity-80" />
|
||||
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-blue-500 ring-2 ring-background" />
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{hasUnreadInbox && (
|
||||
<span className="pointer-events-none absolute -bottom-0.5 -right-0.5 z-10 h-2.5 w-2.5 rounded-full bg-red-500 ring-2 ring-background" />
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
@@ -139,6 +158,36 @@ export function CompanyRail() {
|
||||
() => companies.filter((company) => company.status !== "archived"),
|
||||
[companies],
|
||||
);
|
||||
const companyIds = useMemo(() => sidebarCompanies.map((company) => company.id), [sidebarCompanies]);
|
||||
|
||||
const liveRunsQueries = useQueries({
|
||||
queries: companyIds.map((companyId) => ({
|
||||
queryKey: queryKeys.liveRuns(companyId),
|
||||
queryFn: () => heartbeatsApi.liveRunsForCompany(companyId),
|
||||
refetchInterval: 10_000,
|
||||
})),
|
||||
});
|
||||
const sidebarBadgeQueries = useQueries({
|
||||
queries: companyIds.map((companyId) => ({
|
||||
queryKey: queryKeys.sidebarBadges(companyId),
|
||||
queryFn: () => sidebarBadgesApi.get(companyId),
|
||||
refetchInterval: 15_000,
|
||||
})),
|
||||
});
|
||||
const hasLiveAgentsByCompanyId = useMemo(() => {
|
||||
const result = new Map<string, boolean>();
|
||||
companyIds.forEach((companyId, index) => {
|
||||
result.set(companyId, (liveRunsQueries[index]?.data?.length ?? 0) > 0);
|
||||
});
|
||||
return result;
|
||||
}, [companyIds, liveRunsQueries]);
|
||||
const hasUnreadInboxByCompanyId = useMemo(() => {
|
||||
const result = new Map<string, boolean>();
|
||||
companyIds.forEach((companyId, index) => {
|
||||
result.set(companyId, (sidebarBadgeQueries[index]?.data?.inbox ?? 0) > 0);
|
||||
});
|
||||
return result;
|
||||
}, [companyIds, sidebarBadgeQueries]);
|
||||
|
||||
// Maintain sorted order in local state, synced from companies + localStorage
|
||||
const [orderedIds, setOrderedIds] = useState<string[]>(() =>
|
||||
@@ -219,7 +268,7 @@ export function CompanyRail() {
|
||||
</div>
|
||||
|
||||
{/* Company list */}
|
||||
<div className="flex-1 flex flex-col items-center gap-2 py-2 overflow-y-auto scrollbar-none">
|
||||
<div className="flex-1 flex flex-col items-center gap-2 py-3 w-full overflow-y-auto overflow-x-hidden scrollbar-none">
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
@@ -234,6 +283,8 @@ export function CompanyRail() {
|
||||
key={company.id}
|
||||
company={company}
|
||||
isSelected={company.id === selectedCompanyId}
|
||||
hasLiveAgents={hasLiveAgentsByCompanyId.get(company.id) ?? false}
|
||||
hasUnreadInbox={hasUnreadInboxByCompanyId.get(company.id) ?? false}
|
||||
onSelect={() => setSelectedCompanyId(company.id)}
|
||||
/>
|
||||
))}
|
||||
@@ -250,7 +301,8 @@ export function CompanyRail() {
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => openOnboarding()}
|
||||
className="flex items-center justify-center w-11 h-11 rounded-[22px] hover:rounded-[14px] border-2 border-dashed border-border text-muted-foreground hover:border-foreground/30 hover:text-foreground transition-all duration-200"
|
||||
className="flex items-center justify-center w-11 h-11 rounded-[22px] hover:rounded-[14px] border-2 border-dashed border-border text-muted-foreground hover:border-foreground/30 hover:text-foreground transition-[border-color,color,border-radius] duration-150"
|
||||
aria-label="Add company"
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ChevronsUpDown, Plus, Settings } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Link } from "@/lib/router";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import {
|
||||
DropdownMenu,
|
||||
|
||||
@@ -12,15 +12,21 @@ interface CopyTextProps {
|
||||
|
||||
export function CopyText({ text, children, className, copiedLabel = "Copied!" }: CopyTextProps) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [label, setLabel] = useState(copiedLabel);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
navigator.clipboard.writeText(text);
|
||||
const handleClick = useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setLabel(copiedLabel);
|
||||
} catch {
|
||||
setLabel("Copy failed");
|
||||
}
|
||||
clearTimeout(timerRef.current);
|
||||
setVisible(true);
|
||||
timerRef.current = setTimeout(() => setVisible(false), 1500);
|
||||
}, [text]);
|
||||
}, [copiedLabel, text]);
|
||||
|
||||
return (
|
||||
<span className="relative inline-flex">
|
||||
@@ -36,12 +42,14 @@ export function CopyText({ text, children, className, copiedLabel = "Copied!" }:
|
||||
{children ?? text}
|
||||
</button>
|
||||
<span
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className={cn(
|
||||
"pointer-events-none absolute left-1/2 -translate-x-1/2 bottom-full mb-1.5 rounded-md bg-foreground text-background px-2 py-1 text-xs whitespace-nowrap transition-opacity duration-300",
|
||||
visible ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
>
|
||||
{copiedLabel}
|
||||
{label}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type ReactNode } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Link } from "@/lib/router";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
interface EntityRowProps {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Link } from "@/lib/router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { Goal } from "@paperclip/shared";
|
||||
import { GOAL_STATUSES, GOAL_LEVELS } from "@paperclip/shared";
|
||||
@@ -8,11 +8,10 @@ import { goalsApi } from "../api/goals";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { StatusBadge } from "./StatusBadge";
|
||||
import { formatDate } from "../lib/utils";
|
||||
import { formatDate, cn, agentUrl } from "../lib/utils";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
interface GoalPropertiesProps {
|
||||
goal: Goal;
|
||||
@@ -128,7 +127,7 @@ export function GoalProperties({ goal, onUpdate }: GoalPropertiesProps) {
|
||||
<PropertyRow label="Owner">
|
||||
{ownerAgent ? (
|
||||
<Link
|
||||
to={`/agents/${ownerAgent.id}`}
|
||||
to={agentUrl(ownerAgent)}
|
||||
className="text-sm hover:underline"
|
||||
>
|
||||
{ownerAgent.name}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Goal } from "@paperclip/shared";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Link } from "@/lib/router";
|
||||
import { StatusBadge } from "./StatusBadge";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
@@ -106,6 +106,7 @@ export const InlineEntitySelector = forwardRef<HTMLButtonElement, InlineEntitySe
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
collisionPadding={16}
|
||||
className="w-[min(20rem,calc(100vw-2rem))] p-1"
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
@@ -157,7 +158,10 @@ export const InlineEntitySelector = forwardRef<HTMLButtonElement, InlineEntitySe
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="max-h-56 overflow-y-auto overscroll-contain py-1">
|
||||
<div
|
||||
className="max-h-56 overflow-y-auto overscroll-contain py-1 touch-pan-y"
|
||||
style={{ WebkitOverflowScrolling: "touch" }}
|
||||
>
|
||||
{filteredOptions.length === 0 ? (
|
||||
<p className="px-2 py-2 text-xs text-muted-foreground">{emptyMessage}</p>
|
||||
) : (
|
||||
@@ -169,7 +173,7 @@ export const InlineEntitySelector = forwardRef<HTMLButtonElement, InlineEntitySe
|
||||
key={option.id || "__none__"}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm",
|
||||
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm touch-pan-y",
|
||||
isHighlighted && "bg-accent",
|
||||
)}
|
||||
onMouseEnter={() => setHighlightedIndex(index)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Link } from "@/lib/router";
|
||||
import type { Issue } from "@paperclip/shared";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { agentsApi } from "../api/agents";
|
||||
@@ -12,7 +12,7 @@ import { useProjectOrder } from "../hooks/useProjectOrder";
|
||||
import { StatusIcon } from "./StatusIcon";
|
||||
import { PriorityIcon } from "./PriorityIcon";
|
||||
import { Identity } from "./Identity";
|
||||
import { formatDate, cn } from "../lib/utils";
|
||||
import { formatDate, cn, projectUrl } from "../lib/utils";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
@@ -175,6 +175,11 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
|
||||
const project = orderedProjects.find((p) => p.id === id);
|
||||
return project?.name ?? id.slice(0, 8);
|
||||
};
|
||||
const projectLink = (id: string | null) => {
|
||||
if (!id) return null;
|
||||
const project = projects?.find((p) => p.id === id) ?? null;
|
||||
return project ? projectUrl(project) : `/projects/${id}`;
|
||||
};
|
||||
|
||||
const assignee = issue.assigneeAgentId
|
||||
? agents?.find((a) => a.id === issue.assigneeAgentId)
|
||||
@@ -283,7 +288,7 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
|
||||
}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
{createLabel.isPending ? "Creating..." : "Create label"}
|
||||
{createLabel.isPending ? "Creating…" : "Create label"}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
@@ -482,7 +487,7 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
|
||||
popoverClassName="w-fit min-w-[11rem]"
|
||||
extra={issue.projectId ? (
|
||||
<Link
|
||||
to={`/projects/${issue.projectId}`}
|
||||
to={projectLink(issue.projectId)!}
|
||||
className="inline-flex items-center justify-center h-5 w-5 rounded hover:bg-accent/50 transition-colors text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Link } from "@/lib/router";
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState, type UIEvent } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { BookOpen, Moon, Sun } from "lucide-react";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { Outlet, useLocation, useNavigate, useParams } from "@/lib/router";
|
||||
import { CompanyRail } from "./CompanyRail";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import { SidebarNavItem } from "./SidebarNavItem";
|
||||
@@ -31,8 +31,11 @@ export function Layout() {
|
||||
const { sidebarOpen, setSidebarOpen, toggleSidebar, isMobile } = useSidebar();
|
||||
const { openNewIssue, openOnboarding } = useDialog();
|
||||
const { panelContent, closePanel } = usePanel();
|
||||
const { companies, loading: companiesLoading, setSelectedCompanyId } = useCompany();
|
||||
const { companies, loading: companiesLoading, selectedCompanyId, setSelectedCompanyId } = useCompany();
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const { companyPrefix } = useParams<{ companyPrefix: string }>();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const onboardingTriggered = useRef(false);
|
||||
const lastMainScrollTop = useRef(0);
|
||||
const [mobileNavVisible, setMobileNavVisible] = useState(true);
|
||||
@@ -52,6 +55,40 @@ export function Layout() {
|
||||
}
|
||||
}, [companies, companiesLoading, openOnboarding, health?.deploymentMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!companyPrefix || companiesLoading || companies.length === 0) return;
|
||||
|
||||
const requestedPrefix = companyPrefix.toUpperCase();
|
||||
const matched = companies.find((company) => company.issuePrefix.toUpperCase() === requestedPrefix);
|
||||
|
||||
if (!matched) {
|
||||
const fallback =
|
||||
(selectedCompanyId ? companies.find((company) => company.id === selectedCompanyId) : null)
|
||||
?? companies[0]!;
|
||||
navigate(`/${fallback.issuePrefix}/dashboard`, { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (companyPrefix !== matched.issuePrefix) {
|
||||
const suffix = location.pathname.replace(/^\/[^/]+/, "");
|
||||
navigate(`/${matched.issuePrefix}${suffix}${location.search}`, { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedCompanyId !== matched.id) {
|
||||
setSelectedCompanyId(matched.id, { source: "route_sync" });
|
||||
}
|
||||
}, [
|
||||
companyPrefix,
|
||||
companies,
|
||||
companiesLoading,
|
||||
location.pathname,
|
||||
location.search,
|
||||
navigate,
|
||||
selectedCompanyId,
|
||||
setSelectedCompanyId,
|
||||
]);
|
||||
|
||||
const togglePanel = useCallback(() => {
|
||||
if (panelContent) closePanel();
|
||||
}, [panelContent, closePanel]);
|
||||
@@ -151,11 +188,19 @@ export function Layout() {
|
||||
|
||||
return (
|
||||
<div className="flex h-dvh bg-background text-foreground overflow-hidden pt-[env(safe-area-inset-top)]">
|
||||
<a
|
||||
href="#main-content"
|
||||
className="sr-only focus:not-sr-only focus:fixed focus:left-3 focus:top-3 focus:z-[200] focus:rounded-md focus:bg-background focus:px-3 focus:py-2 focus:text-sm focus:font-medium focus:shadow-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
Skip to Main Content
|
||||
</a>
|
||||
{/* Mobile backdrop */}
|
||||
{isMobile && sidebarOpen && (
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
className="fixed inset-0 z-40 bg-black/50"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
aria-label="Close sidebar"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -163,7 +208,7 @@ export function Layout() {
|
||||
{isMobile ? (
|
||||
<div
|
||||
className={cn(
|
||||
"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",
|
||||
"fixed inset-y-0 left-0 z-50 flex flex-col overflow-hidden pt-[env(safe-area-inset-top)] transition-transform duration-100 ease-out",
|
||||
sidebarOpen ? "translate-x-0" : "-translate-x-full"
|
||||
)}
|
||||
>
|
||||
@@ -199,7 +244,7 @@ export function Layout() {
|
||||
<CompanyRail />
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden transition-all duration-200 ease-in-out",
|
||||
"overflow-hidden transition-[width] duration-100 ease-out",
|
||||
sidebarOpen ? "w-60" : "w-0"
|
||||
)}
|
||||
>
|
||||
@@ -235,6 +280,8 @@ export function Layout() {
|
||||
<BreadcrumbBar />
|
||||
<div className="flex flex-1 min-h-0">
|
||||
<main
|
||||
id="main-content"
|
||||
tabIndex={-1}
|
||||
className={cn("flex-1 overflow-auto p-4 md:p-6", isMobile && "pb-[calc(5rem+env(safe-area-inset-bottom))]")}
|
||||
onScroll={handleMainScroll}
|
||||
>
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { useEffect, useMemo, useRef, useState, type MutableRefObject } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Link } from "@/lib/router";
|
||||
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 { cn, relativeTime, formatDateTime } from "../lib/utils";
|
||||
import { ExternalLink, Square } from "lucide-react";
|
||||
import { Identity } from "./Identity";
|
||||
import { StatusBadge } from "./StatusBadge";
|
||||
|
||||
interface LiveRunWidgetProps {
|
||||
issueId: string;
|
||||
@@ -311,55 +312,54 @@ export function LiveRunWidget({ issueId, companyId }: LiveRunWidgetProps) {
|
||||
if (runs.length === 0 && feed.length === 0) return null;
|
||||
|
||||
const recent = feed.slice(-25);
|
||||
const headerRun =
|
||||
runs[0] ??
|
||||
(() => {
|
||||
const last = recent[recent.length - 1];
|
||||
if (!last) return null;
|
||||
const meta = runMetaByIdRef.current.get(last.runId);
|
||||
if (!meta) return null;
|
||||
return {
|
||||
id: last.runId,
|
||||
agentId: meta.agentId,
|
||||
};
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-cyan-500/30 bg-background/80 overflow-hidden shadow-[0_0_12px_rgba(6,182,212,0.08)]">
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-border/50">
|
||||
<div className="flex items-center gap-2">
|
||||
{runs.length > 0 && (
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-cyan-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-cyan-400" />
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs font-medium">
|
||||
{runs.length > 0 ? `Live issue runs (${runs.length})` : "Recent run updates"}
|
||||
</span>
|
||||
</div>
|
||||
{headerRun && (
|
||||
<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-600 hover:text-red-500 dark:text-red-400 dark:hover:text-red-300 disabled:opacity-50"
|
||||
{runs.length > 0 ? (
|
||||
runs.map((run) => (
|
||||
<div key={run.id} className="px-3 py-2 border-b border-border/50">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Link to={`/agents/${run.agentId}`} className="hover:underline">
|
||||
<Identity name={run.agentName} size="sm" />
|
||||
</Link>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDateTime(run.startedAt ?? run.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-muted-foreground">Run</span>
|
||||
<Link
|
||||
to={`/agents/${run.agentId}/runs/${run.id}`}
|
||||
className="inline-flex items-center rounded-md border border-border bg-accent/40 px-2 py-1 font-mono text-muted-foreground hover:text-foreground hover:bg-accent/60 transition-colors"
|
||||
>
|
||||
<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-600 hover:text-cyan-500 dark:text-cyan-300 dark:hover:text-cyan-200"
|
||||
>
|
||||
Open run
|
||||
<ExternalLink className="h-2.5 w-2.5" />
|
||||
</Link>
|
||||
{run.id.slice(0, 8)}
|
||||
</Link>
|
||||
<StatusBadge status={run.status} />
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleCancelRun(run.id)}
|
||||
disabled={cancellingRunIds.has(run.id)}
|
||||
className="inline-flex items-center gap-1 text-[10px] text-red-600 hover:text-red-500 dark:text-red-400 dark:hover:text-red-300 disabled:opacity-50"
|
||||
>
|
||||
<Square className="h-2 w-2" fill="currentColor" />
|
||||
{cancellingRunIds.has(run.id) ? "Stopping…" : "Stop"}
|
||||
</button>
|
||||
<Link
|
||||
to={`/agents/${run.agentId}/runs/${run.id}`}
|
||||
className="inline-flex items-center gap-1 text-[10px] text-cyan-600 hover:text-cyan-500 dark:text-cyan-300 dark:hover:text-cyan-200"
|
||||
>
|
||||
Open run
|
||||
<ExternalLink className="h-2.5 w-2.5" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="flex items-center px-3 py-2 border-b border-border/50">
|
||||
<span className="text-xs font-medium text-muted-foreground">Recent run updates</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={bodyRef} className="max-h-[220px] overflow-y-auto p-2 font-mono text-[11px] space-y-1">
|
||||
{recent.length === 0 && (
|
||||
@@ -390,21 +390,6 @@ export function LiveRunWidget({ issueId, companyId }: LiveRunWidgetProps) {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{runs.length > 1 && (
|
||||
<div className="border-t border-border/50 px-3 py-2 flex flex-wrap gap-2">
|
||||
{runs.map((run) => (
|
||||
<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-600 hover:text-cyan-500 dark:text-cyan-300 dark: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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,9 +10,11 @@ import {
|
||||
type DragEvent,
|
||||
} from "react";
|
||||
import {
|
||||
CodeMirrorEditor,
|
||||
MDXEditor,
|
||||
codeBlockPlugin,
|
||||
codeMirrorPlugin,
|
||||
type CodeBlockEditorDescriptor,
|
||||
type MDXEditorMethods,
|
||||
headingsPlugin,
|
||||
imagePlugin,
|
||||
@@ -90,6 +92,14 @@ const CODE_BLOCK_LANGUAGES: Record<string, string> = {
|
||||
yml: "YAML",
|
||||
};
|
||||
|
||||
const FALLBACK_CODE_BLOCK_DESCRIPTOR: CodeBlockEditorDescriptor = {
|
||||
// Keep this lower than codeMirrorPlugin's descriptor priority so known languages
|
||||
// still use the standard matching path; this catches malformed/unknown fences.
|
||||
priority: 0,
|
||||
match: () => true,
|
||||
Editor: CodeMirrorEditor,
|
||||
};
|
||||
|
||||
function detectMention(container: HTMLElement): MentionState | null {
|
||||
const sel = window.getSelection();
|
||||
if (!sel || sel.rangeCount === 0 || !sel.isCollapsed) return null;
|
||||
@@ -247,7 +257,10 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||
linkPlugin(),
|
||||
linkDialogPlugin(),
|
||||
thematicBreakPlugin(),
|
||||
codeBlockPlugin(),
|
||||
codeBlockPlugin({
|
||||
defaultCodeBlockLanguage: "txt",
|
||||
codeBlockEditorDescriptors: [FALLBACK_CODE_BLOCK_DESCRIPTOR],
|
||||
}),
|
||||
codeMirrorPlugin({ codeBlockLanguages: CODE_BLOCK_LANGUAGES }),
|
||||
markdownShortcutPlugin(),
|
||||
];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Link } from "@/lib/router";
|
||||
|
||||
interface MetricCardProps {
|
||||
icon: LucideIcon;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo } from "react";
|
||||
import { NavLink, useLocation } from "react-router-dom";
|
||||
import { NavLink, useLocation } from "@/lib/router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
House,
|
||||
@@ -75,7 +75,7 @@ export function MobileBottomNav({ visible }: MobileBottomNavProps) {
|
||||
{items.map((item) => {
|
||||
if (item.type === "action") {
|
||||
const Icon = item.icon;
|
||||
const active = location.pathname.startsWith("/issues/new");
|
||||
const active = /\/issues\/new(?:\/|$)/.test(location.pathname);
|
||||
return (
|
||||
<button
|
||||
key={item.label}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useNavigate } from "@/lib/router";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { agentsApi } from "../api/agents";
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
Shield,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import { cn } from "../lib/utils";
|
||||
import { cn, agentUrl } from "../lib/utils";
|
||||
import { roleLabels } from "./agent-config-primitives";
|
||||
import { AgentConfigForm, type CreateConfigValues } from "./AgentConfigForm";
|
||||
import { defaultCreateValues } from "./agent-config-defaults";
|
||||
@@ -80,7 +80,7 @@ export function NewAgentDialog() {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(selectedCompanyId!) });
|
||||
reset();
|
||||
closeNewAgent();
|
||||
navigate(`/agents/${result.agent.id}`);
|
||||
navigate(agentUrl(result.agent));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -286,7 +286,7 @@ export function NewAgentDialog() {
|
||||
disabled={!name.trim() || createAgent.isPending}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{createAgent.isPending ? "Creating..." : "Create agent"}
|
||||
{createAgent.isPending ? "Creating…" : "Create agent"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
@@ -273,7 +273,7 @@ export function NewGoalDialog() {
|
||||
disabled={!title.trim() || createGoal.isPending}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{createGoal.isPending ? "Creating..." : newGoalDefaults.parentId ? "Create sub-goal" : "Create goal"}
|
||||
{createGoal.isPending ? "Creating…" : newGoalDefaults.parentId ? "Create sub-goal" : "Create goal"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
@@ -468,7 +468,7 @@ export function NewProjectDialog() {
|
||||
disabled={!name.trim() || createProject.isPending}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{createProject.isPending ? "Creating..." : "Create project"}
|
||||
{createProject.isPending ? "Creating…" : "Create project"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,26 +1,160 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
interface PageSkeletonProps {
|
||||
variant?: "list" | "detail";
|
||||
variant?:
|
||||
| "list"
|
||||
| "issues-list"
|
||||
| "detail"
|
||||
| "dashboard"
|
||||
| "approvals"
|
||||
| "costs"
|
||||
| "inbox"
|
||||
| "org-chart";
|
||||
}
|
||||
|
||||
export function PageSkeleton({ variant = "list" }: PageSkeletonProps) {
|
||||
if (variant === "dashboard") {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-32 w-full border border-border" />
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 xl:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-24 w-full" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-44 w-full" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Skeleton className="h-72 w-full" />
|
||||
<Skeleton className="h-72 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "approvals") {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-9 w-44" />
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-36 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "costs") {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-9 w-28" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Skeleton className="h-40 w-full" />
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Skeleton className="h-72 w-full" />
|
||||
<Skeleton className="h-72 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "inbox") {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-9 w-56" />
|
||||
<Skeleton className="h-8 w-40" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
{Array.from({ length: 3 }).map((_, section) => (
|
||||
<div key={section} className="space-y-2">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<div className="space-y-1 border border-border">
|
||||
{Array.from({ length: 3 }).map((_, row) => (
|
||||
<Skeleton key={row} className="h-14 w-full rounded-none" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "org-chart") {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-[calc(100vh-4rem)] w-full rounded-lg border border-border" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "detail") {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="h-4 w-full max-w-md" />
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-3 w-64" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-6 w-6" />
|
||||
<Skeleton className="h-6 w-6" />
|
||||
<Skeleton className="h-7 w-48" />
|
||||
</div>
|
||||
<Skeleton className="h-4 w-40" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-8 w-24" />
|
||||
<Skeleton className="h-8 w-24" />
|
||||
<Skeleton className="h-8 w-24" />
|
||||
</div>
|
||||
<Skeleton className="h-24 w-full" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "issues-list") {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Skeleton className="h-9 w-64" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-8 w-16" />
|
||||
<Skeleton className="h-8 w-16" />
|
||||
<Skeleton className="h-8 w-16" />
|
||||
<Skeleton className="h-8 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<div className="space-y-1">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-11 w-full rounded-none" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -28,14 +162,17 @@ export function PageSkeleton({ variant = "list" }: PageSkeletonProps) {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-6 w-32" />
|
||||
<Skeleton className="h-8 w-24" />
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Skeleton className="h-9 w-44" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-8 w-20" />
|
||||
<Skeleton className="h-8 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="h-10 w-full max-w-sm" />
|
||||
|
||||
<div className="space-y-1">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-10 w-full" />
|
||||
{Array.from({ length: 7 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-11 w-full rounded-none" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Link } from "@/lib/router";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { Project } from "@paperclip/shared";
|
||||
import { StatusBadge } from "./StatusBadge";
|
||||
|
||||
@@ -21,7 +21,6 @@ import { sidebarBadgesApi } from "../api/sidebarBadges";
|
||||
import { heartbeatsApi } from "../api/heartbeats";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
|
||||
export function Sidebar() {
|
||||
const { openNewIssue } = useDialog();
|
||||
@@ -66,45 +65,43 @@ export function Sidebar() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1">
|
||||
<nav className="flex flex-col gap-4 px-3 py-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{/* New Issue button aligned with nav items */}
|
||||
<button
|
||||
onClick={() => openNewIssue()}
|
||||
className="flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium text-muted-foreground hover:bg-accent/50 hover:text-foreground transition-colors"
|
||||
>
|
||||
<SquarePen className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">New Issue</span>
|
||||
</button>
|
||||
<SidebarNavItem to="/dashboard" label="Dashboard" icon={LayoutDashboard} liveCount={liveRunCount} />
|
||||
<SidebarNavItem
|
||||
to="/inbox"
|
||||
label="Inbox"
|
||||
icon={Inbox}
|
||||
badge={sidebarBadges?.inbox}
|
||||
badgeTone={sidebarBadges?.failedRuns ? "danger" : "default"}
|
||||
alert={(sidebarBadges?.failedRuns ?? 0) > 0}
|
||||
/>
|
||||
</div>
|
||||
<nav className="flex-1 min-h-0 overflow-y-auto scrollbar-none flex flex-col gap-4 px-3 py-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{/* New Issue button aligned with nav items */}
|
||||
<button
|
||||
onClick={() => openNewIssue()}
|
||||
className="flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium text-muted-foreground hover:bg-accent/50 hover:text-foreground transition-colors"
|
||||
>
|
||||
<SquarePen className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">New Issue</span>
|
||||
</button>
|
||||
<SidebarNavItem to="/dashboard" label="Dashboard" icon={LayoutDashboard} liveCount={liveRunCount} />
|
||||
<SidebarNavItem
|
||||
to="/inbox"
|
||||
label="Inbox"
|
||||
icon={Inbox}
|
||||
badge={sidebarBadges?.inbox}
|
||||
badgeTone={sidebarBadges?.failedRuns ? "danger" : "default"}
|
||||
alert={(sidebarBadges?.failedRuns ?? 0) > 0}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SidebarSection label="Work">
|
||||
<SidebarNavItem to="/issues" label="Issues" icon={CircleDot} />
|
||||
<SidebarNavItem to="/goals" label="Goals" icon={Target} />
|
||||
</SidebarSection>
|
||||
<SidebarSection label="Work">
|
||||
<SidebarNavItem to="/issues" label="Issues" icon={CircleDot} />
|
||||
<SidebarNavItem to="/goals" label="Goals" icon={Target} />
|
||||
</SidebarSection>
|
||||
|
||||
<SidebarProjects />
|
||||
<SidebarProjects />
|
||||
|
||||
<SidebarAgents />
|
||||
<SidebarAgents />
|
||||
|
||||
<SidebarSection label="Company">
|
||||
<SidebarNavItem to="/org" label="Org" icon={Network} />
|
||||
<SidebarNavItem to="/costs" label="Costs" icon={DollarSign} />
|
||||
<SidebarNavItem to="/activity" label="Activity" icon={History} />
|
||||
<SidebarNavItem to="/company/settings" label="Settings" icon={Settings} />
|
||||
</SidebarSection>
|
||||
</nav>
|
||||
</ScrollArea>
|
||||
<SidebarSection label="Company">
|
||||
<SidebarNavItem to="/org" label="Org" icon={Network} />
|
||||
<SidebarNavItem to="/costs" label="Costs" icon={DollarSign} />
|
||||
<SidebarNavItem to="/activity" label="Activity" icon={History} />
|
||||
<SidebarNavItem to="/company/settings" label="Settings" icon={Settings} />
|
||||
</SidebarSection>
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { NavLink, useLocation } from "react-router-dom";
|
||||
import { NavLink, useLocation } from "@/lib/router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
@@ -7,7 +7,7 @@ import { useSidebar } from "../context/SidebarContext";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { heartbeatsApi } from "../api/heartbeats";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { cn } from "../lib/utils";
|
||||
import { cn, agentRouteRef, agentUrl } from "../lib/utils";
|
||||
import { AgentIcon } from "./AgentIconPicker";
|
||||
import {
|
||||
Collapsible,
|
||||
@@ -71,7 +71,7 @@ export function SidebarAgents() {
|
||||
return sortByHierarchy(filtered);
|
||||
}, [agents]);
|
||||
|
||||
const agentMatch = location.pathname.match(/^\/agents\/([^/]+)/);
|
||||
const agentMatch = location.pathname.match(/^\/(?:[^/]+\/)?agents\/([^/]+)/);
|
||||
const activeAgentId = agentMatch?.[1] ?? null;
|
||||
|
||||
return (
|
||||
@@ -99,13 +99,13 @@ export function SidebarAgents() {
|
||||
return (
|
||||
<NavLink
|
||||
key={agent.id}
|
||||
to={`/agents/${agent.id}`}
|
||||
to={agentUrl(agent)}
|
||||
onClick={() => {
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center gap-2.5 px-3 py-1.5 text-[13px] font-medium transition-colors",
|
||||
activeAgentId === agent.id
|
||||
activeAgentId === agentRouteRef(agent)
|
||||
? "bg-accent text-foreground"
|
||||
: "text-foreground/80 hover:bg-accent/50 hover:text-foreground"
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { NavLink } from "react-router-dom";
|
||||
import { NavLink } from "@/lib/router";
|
||||
import { cn } from "../lib/utils";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { NavLink, useLocation } from "react-router-dom";
|
||||
import { NavLink, useLocation } from "@/lib/router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ChevronRight, Plus } from "lucide-react";
|
||||
import {
|
||||
@@ -18,7 +18,7 @@ import { useSidebar } from "../context/SidebarContext";
|
||||
import { authApi } from "../api/auth";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { cn } from "../lib/utils";
|
||||
import { cn, projectRouteRef } from "../lib/utils";
|
||||
import { useProjectOrder } from "../hooks/useProjectOrder";
|
||||
import {
|
||||
Collapsible,
|
||||
@@ -28,12 +28,12 @@ import {
|
||||
import type { Project } from "@paperclip/shared";
|
||||
|
||||
function SortableProjectItem({
|
||||
activeProjectId,
|
||||
activeProjectRef,
|
||||
isMobile,
|
||||
project,
|
||||
setSidebarOpen,
|
||||
}: {
|
||||
activeProjectId: string | null;
|
||||
activeProjectRef: string | null;
|
||||
isMobile: boolean;
|
||||
project: Project;
|
||||
setSidebarOpen: (open: boolean) => void;
|
||||
@@ -47,6 +47,8 @@ function SortableProjectItem({
|
||||
isDragging,
|
||||
} = useSortable({ id: project.id });
|
||||
|
||||
const routeRef = projectRouteRef(project);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
@@ -60,13 +62,13 @@ function SortableProjectItem({
|
||||
{...listeners}
|
||||
>
|
||||
<NavLink
|
||||
to={`/projects/${project.id}/issues`}
|
||||
to={`/projects/${routeRef}/issues`}
|
||||
onClick={() => {
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center gap-2.5 px-3 py-1.5 text-[13px] font-medium transition-colors",
|
||||
activeProjectId === project.id
|
||||
activeProjectRef === routeRef || activeProjectRef === project.id
|
||||
? "bg-accent text-foreground"
|
||||
: "text-foreground/80 hover:bg-accent/50 hover:text-foreground",
|
||||
)}
|
||||
@@ -110,8 +112,8 @@ export function SidebarProjects() {
|
||||
userId: currentUserId,
|
||||
});
|
||||
|
||||
const projectMatch = location.pathname.match(/^\/projects\/([^/]+)/);
|
||||
const activeProjectId = projectMatch?.[1] ?? null;
|
||||
const projectMatch = location.pathname.match(/^\/(?:[^/]+\/)?projects\/([^/]+)/);
|
||||
const activeProjectRef = projectMatch?.[1] ?? null;
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: { distance: 8 },
|
||||
@@ -175,7 +177,7 @@ export function SidebarProjects() {
|
||||
{orderedProjects.map((project: Project) => (
|
||||
<SortableProjectItem
|
||||
key={project.id}
|
||||
activeProjectId={activeProjectId}
|
||||
activeProjectRef={activeProjectRef}
|
||||
isMobile={isMobile}
|
||||
project={project}
|
||||
setSidebarOpen={setSidebarOpen}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Link } from "@/lib/router";
|
||||
import { X } from "lucide-react";
|
||||
import { useToast, type ToastItem, type ToastTone } from "../context/ToastContext";
|
||||
import { cn } from "../lib/utils";
|
||||
@@ -35,7 +35,7 @@ function AnimatedToast({
|
||||
return (
|
||||
<li
|
||||
className={cn(
|
||||
"pointer-events-auto rounded-sm border shadow-lg backdrop-blur-xl transition-all duration-300 ease-out",
|
||||
"pointer-events-auto rounded-sm border shadow-lg backdrop-blur-xl transition-[transform,opacity] duration-200 ease-out",
|
||||
visible
|
||||
? "translate-y-0 opacity-100"
|
||||
: "translate-y-3 opacity-0",
|
||||
|
||||
@@ -4,6 +4,15 @@ import {
|
||||
TooltipTrigger,
|
||||
TooltipContent,
|
||||
} from "@/components/ui/tooltip";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HelpCircle, ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
@@ -23,10 +32,9 @@ export const help: Record<string, string> = {
|
||||
dangerouslySkipPermissions: "Run Claude without permission prompts. Required for unattended operation.",
|
||||
dangerouslyBypassSandbox: "Run Codex without sandbox restrictions. Required for filesystem/network access.",
|
||||
search: "Enable Codex web search capability during runs.",
|
||||
bootstrapPrompt: "Prompt used only on the first run (no existing session). Used for initial agent setup.",
|
||||
maxTurnsPerRun: "Maximum number of agentic turns (tool calls) per heartbeat run.",
|
||||
command: "The command to execute (e.g. node, python).",
|
||||
localCommand: "Override the local CLI command (e.g. claude, /usr/local/bin/claude, codex).",
|
||||
localCommand: "Override the path to the CLI command you want the adapter to call (e.g. /usr/local/bin/claude, codex).",
|
||||
args: "Command-line arguments, comma-separated.",
|
||||
extraArgs: "Extra CLI arguments for local adapters, comma-separated.",
|
||||
envVars: "Environment variables injected into the adapter process. Use plain values or secret references.",
|
||||
@@ -372,3 +380,87 @@ export function DraftNumberInput({
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Choose" button that opens a dialog explaining the user must manually
|
||||
* type the path due to browser security limitations.
|
||||
*/
|
||||
export function ChoosePathButton() {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground hover:bg-accent/50 transition-colors shrink-0"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
Choose
|
||||
</button>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Specify path manually</DialogTitle>
|
||||
<DialogDescription>
|
||||
Browser security blocks apps from reading full local paths via a file picker.
|
||||
Copy the absolute path and paste it into the input.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 text-sm">
|
||||
<section className="space-y-1.5">
|
||||
<p className="font-medium">macOS (Finder)</p>
|
||||
<ol className="list-decimal space-y-1 pl-5 text-muted-foreground">
|
||||
<li>Find the folder in Finder.</li>
|
||||
<li>Hold <kbd>Option</kbd> and right-click the folder.</li>
|
||||
<li>Click "Copy <folder name> as Pathname".</li>
|
||||
<li>Paste the result into the path input.</li>
|
||||
</ol>
|
||||
<p className="rounded-md bg-muted px-2 py-1 font-mono text-xs">
|
||||
/Users/yourname/Documents/project
|
||||
</p>
|
||||
</section>
|
||||
<section className="space-y-1.5">
|
||||
<p className="font-medium">Windows (File Explorer)</p>
|
||||
<ol className="list-decimal space-y-1 pl-5 text-muted-foreground">
|
||||
<li>Find the folder in File Explorer.</li>
|
||||
<li>Hold <kbd>Shift</kbd> and right-click the folder.</li>
|
||||
<li>Click "Copy as path".</li>
|
||||
<li>Paste the result into the path input.</li>
|
||||
</ol>
|
||||
<p className="rounded-md bg-muted px-2 py-1 font-mono text-xs">
|
||||
C:\Users\yourname\Documents\project
|
||||
</p>
|
||||
</section>
|
||||
<section className="space-y-1.5">
|
||||
<p className="font-medium">Terminal fallback (macOS/Linux)</p>
|
||||
<ol className="list-decimal space-y-1 pl-5 text-muted-foreground">
|
||||
<li>Run <code>cd /path/to/folder</code>.</li>
|
||||
<li>Run <code>pwd</code>.</li>
|
||||
<li>Copy the output and paste it into the path input.</li>
|
||||
</ol>
|
||||
</section>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
OK
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Label + input rendered on the same line (inline layout for compact fields).
|
||||
*/
|
||||
export function InlineField({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<label className="text-xs text-muted-foreground">{label}</label>
|
||||
{hint && <HintIcon text={hint} />}
|
||||
</div>
|
||||
<div className="w-24 ml-auto">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Slot } from "radix-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,background-color,border-color,box-shadow,opacity] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
@@ -21,13 +21,13 @@ const buttonVariants = cva(
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
default: "h-10 px-4 py-2 has-[>svg]:px-3",
|
||||
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
sm: "h-9 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
icon: "size-10",
|
||||
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm": "size-8",
|
||||
"icon-sm": "size-9",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -4,7 +4,7 @@ function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("bg-accent animate-pulse rounded-md", className)}
|
||||
className={cn("bg-accent/75 rounded-md", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -62,7 +62,7 @@ function TabsTrigger({
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,background-color,border-color,box-shadow] group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
|
||||
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 data-[state=active]:text-foreground",
|
||||
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
|
||||
|
||||
Reference in New Issue
Block a user