Merge pull request #613 from paperclipai/public/inbox-runs-worktree-history
Polish inbox workflows, agent runs, and worktree setup
This commit is contained in:
@@ -115,6 +115,28 @@ describe("worktree helpers", () => {
|
|||||||
).toEqual(["worktree", "add", "/tmp/feature-branch", "feature-branch"]);
|
).toEqual(["worktree", "add", "/tmp/feature-branch", "feature-branch"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("builds git worktree add args with a start point", () => {
|
||||||
|
expect(
|
||||||
|
resolveGitWorktreeAddArgs({
|
||||||
|
branchName: "my-worktree",
|
||||||
|
targetPath: "/tmp/my-worktree",
|
||||||
|
branchExists: false,
|
||||||
|
startPoint: "public-gh/master",
|
||||||
|
}),
|
||||||
|
).toEqual(["worktree", "add", "-b", "my-worktree", "/tmp/my-worktree", "public-gh/master"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses start point even when a local branch with the same name exists", () => {
|
||||||
|
expect(
|
||||||
|
resolveGitWorktreeAddArgs({
|
||||||
|
branchName: "my-worktree",
|
||||||
|
targetPath: "/tmp/my-worktree",
|
||||||
|
branchExists: true,
|
||||||
|
startPoint: "origin/main",
|
||||||
|
}),
|
||||||
|
).toEqual(["worktree", "add", "-b", "my-worktree", "/tmp/my-worktree", "origin/main"]);
|
||||||
|
});
|
||||||
|
|
||||||
it("rewrites loopback auth URLs to the new port only", () => {
|
it("rewrites loopback auth URLs to the new port only", () => {
|
||||||
expect(rewriteLocalUrlPort("http://127.0.0.1:3100", 3110)).toBe("http://127.0.0.1:3110/");
|
expect(rewriteLocalUrlPort("http://127.0.0.1:3100", 3110)).toBe("http://127.0.0.1:3110/");
|
||||||
expect(rewriteLocalUrlPort("https://paperclip.example", 3110)).toBe("https://paperclip.example");
|
expect(rewriteLocalUrlPort("https://paperclip.example", 3110)).toBe("https://paperclip.example");
|
||||||
|
|||||||
@@ -62,7 +62,9 @@ type WorktreeInitOptions = {
|
|||||||
force?: boolean;
|
force?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type WorktreeMakeOptions = WorktreeInitOptions;
|
type WorktreeMakeOptions = WorktreeInitOptions & {
|
||||||
|
startPoint?: string;
|
||||||
|
};
|
||||||
|
|
||||||
type WorktreeEnvOptions = {
|
type WorktreeEnvOptions = {
|
||||||
config?: string;
|
config?: string;
|
||||||
@@ -166,11 +168,13 @@ export function resolveGitWorktreeAddArgs(input: {
|
|||||||
branchName: string;
|
branchName: string;
|
||||||
targetPath: string;
|
targetPath: string;
|
||||||
branchExists: boolean;
|
branchExists: boolean;
|
||||||
|
startPoint?: string;
|
||||||
}): string[] {
|
}): string[] {
|
||||||
if (input.branchExists) {
|
if (input.branchExists && !input.startPoint) {
|
||||||
return ["worktree", "add", input.targetPath, input.branchName];
|
return ["worktree", "add", input.targetPath, input.branchName];
|
||||||
}
|
}
|
||||||
return ["worktree", "add", "-b", input.branchName, input.targetPath, "HEAD"];
|
const commitish = input.startPoint ?? "HEAD";
|
||||||
|
return ["worktree", "add", "-b", input.branchName, input.targetPath, commitish];
|
||||||
}
|
}
|
||||||
|
|
||||||
function readPidFilePort(postmasterPidFile: string): number | null {
|
function readPidFilePort(postmasterPidFile: string): number | null {
|
||||||
@@ -715,10 +719,25 @@ export async function worktreeMakeCommand(nameArg: string, opts: WorktreeMakeOpt
|
|||||||
}
|
}
|
||||||
|
|
||||||
mkdirSync(path.dirname(targetPath), { recursive: true });
|
mkdirSync(path.dirname(targetPath), { recursive: true });
|
||||||
|
if (opts.startPoint) {
|
||||||
|
const [remote] = opts.startPoint.split("/", 1);
|
||||||
|
try {
|
||||||
|
execFileSync("git", ["fetch", remote], {
|
||||||
|
cwd: sourceCwd,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to fetch from remote "${remote}": ${extractExecSyncErrorMessage(error) ?? String(error)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const worktreeArgs = resolveGitWorktreeAddArgs({
|
const worktreeArgs = resolveGitWorktreeAddArgs({
|
||||||
branchName: name,
|
branchName: name,
|
||||||
targetPath,
|
targetPath,
|
||||||
branchExists: localBranchExists(sourceCwd, name),
|
branchExists: !opts.startPoint && localBranchExists(sourceCwd, name),
|
||||||
|
startPoint: opts.startPoint,
|
||||||
});
|
});
|
||||||
|
|
||||||
const spinner = p.spinner();
|
const spinner = p.spinner();
|
||||||
@@ -775,6 +794,7 @@ export function registerWorktreeCommands(program: Command): void {
|
|||||||
.command("worktree:make")
|
.command("worktree:make")
|
||||||
.description("Create ~/NAME as a git worktree, then initialize an isolated Paperclip instance inside it")
|
.description("Create ~/NAME as a git worktree, then initialize an isolated Paperclip instance inside it")
|
||||||
.argument("<name>", "Worktree directory and branch name (created at ~/NAME)")
|
.argument("<name>", "Worktree directory and branch name (created at ~/NAME)")
|
||||||
|
.option("--start-point <ref>", "Remote ref to base the new branch on (e.g. origin/main)")
|
||||||
.option("--instance <id>", "Explicit isolated instance id")
|
.option("--instance <id>", "Explicit isolated instance id")
|
||||||
.option("--home <path>", `Home root for worktree instances (default: ${DEFAULT_WORKTREE_HOME})`)
|
.option("--home <path>", `Home root for worktree instances (default: ${DEFAULT_WORKTREE_HOME})`)
|
||||||
.option("--from-config <path>", "Source config.json to seed from")
|
.option("--from-config <path>", "Source config.json to seed from")
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ These decisions close open questions from `SPEC.md` for V1.
|
|||||||
| Visibility | Full visibility to board and all agents in same company |
|
| Visibility | Full visibility to board and all agents in same company |
|
||||||
| Communication | Tasks + comments only (no separate chat system) |
|
| Communication | Tasks + comments only (no separate chat system) |
|
||||||
| Task ownership | Single assignee; atomic checkout required for `in_progress` transition |
|
| Task ownership | Single assignee; atomic checkout required for `in_progress` transition |
|
||||||
| Recovery | No automatic reassignment; stale work is surfaced, not silently fixed |
|
| Recovery | No automatic reassignment; work recovery stays manual/explicit |
|
||||||
| Agent adapters | Built-in `process` and `http` adapters |
|
| Agent adapters | Built-in `process` and `http` adapters |
|
||||||
| Auth | Mode-dependent human auth (`local_trusted` implicit board in current code; authenticated mode uses sessions), API keys for agents |
|
| Auth | Mode-dependent human auth (`local_trusted` implicit board in current code; authenticated mode uses sessions), API keys for agents |
|
||||||
| Budget period | Monthly UTC calendar window |
|
| Budget period | Monthly UTC calendar window |
|
||||||
@@ -106,7 +106,6 @@ A lightweight scheduler/worker in the server process handles:
|
|||||||
- heartbeat trigger checks
|
- heartbeat trigger checks
|
||||||
- stuck run detection
|
- stuck run detection
|
||||||
- budget threshold checks
|
- budget threshold checks
|
||||||
- stale task reporting generation
|
|
||||||
|
|
||||||
Separate queue infrastructure is not required for V1.
|
Separate queue infrastructure is not required for V1.
|
||||||
|
|
||||||
@@ -502,7 +501,6 @@ Dashboard payload must include:
|
|||||||
- open/in-progress/blocked/done issue counts
|
- open/in-progress/blocked/done issue counts
|
||||||
- month-to-date spend and budget utilization
|
- month-to-date spend and budget utilization
|
||||||
- pending approvals count
|
- pending approvals count
|
||||||
- stale task count
|
|
||||||
|
|
||||||
## 10.9 Error Semantics
|
## 10.9 Error Semantics
|
||||||
|
|
||||||
@@ -681,7 +679,6 @@ Required UX behaviors:
|
|||||||
- global company selector
|
- global company selector
|
||||||
- quick actions: pause/resume agent, create task, approve/reject request
|
- quick actions: pause/resume agent, create task, approve/reject request
|
||||||
- conflict toasts on atomic checkout failure
|
- conflict toasts on atomic checkout failure
|
||||||
- clear stale-task indicators
|
|
||||||
- no silent background failures; every failed run visible in UI
|
- no silent background failures; every failed run visible in UI
|
||||||
|
|
||||||
## 15. Operational Requirements
|
## 15. Operational Requirements
|
||||||
@@ -780,7 +777,6 @@ A release candidate is blocked unless these pass:
|
|||||||
|
|
||||||
- add company selector and org chart view
|
- add company selector and org chart view
|
||||||
- add approvals and cost pages
|
- add approvals and cost pages
|
||||||
- add operational dashboard and stale-task surfacing
|
|
||||||
|
|
||||||
## Milestone 6: Hardening and Release
|
## Milestone 6: Hardening and Release
|
||||||
|
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ No section header — these are always at the top, below the company header.
|
|||||||
My Issues
|
My Issues
|
||||||
```
|
```
|
||||||
|
|
||||||
- **Inbox** — items requiring the board operator's attention. Badge count on the right. Includes: pending approvals, stale tasks, budget alerts, failed heartbeats. The number is the total unread/unresolved count.
|
- **Inbox** — items requiring the board operator's attention. Badge count on the right. Includes: pending approvals, budget alerts, failed heartbeats. The number is the total unread/unresolved count.
|
||||||
- **My Issues** — issues created by or assigned to the board operator.
|
- **My Issues** — issues created by or assigned to the board operator.
|
||||||
|
|
||||||
### 3.3 Work Section
|
### 3.3 Work Section
|
||||||
|
|||||||
@@ -18,5 +18,4 @@ export interface DashboardSummary {
|
|||||||
monthUtilizationPercent: number;
|
monthUtilizationPercent: number;
|
||||||
};
|
};
|
||||||
pendingApprovals: number;
|
pendingApprovals: number;
|
||||||
staleTasks: number;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1346,6 +1346,17 @@ export function agentRoutes(db: Db) {
|
|||||||
res.json(liveRuns);
|
res.json(liveRuns);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get("/heartbeat-runs/:runId", async (req, res) => {
|
||||||
|
const runId = req.params.runId as string;
|
||||||
|
const run = await heartbeat.getRun(runId);
|
||||||
|
if (!run) {
|
||||||
|
res.status(404).json({ error: "Heartbeat run not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
assertCompanyAccess(req, run.companyId);
|
||||||
|
res.json(run);
|
||||||
|
});
|
||||||
|
|
||||||
router.post("/heartbeat-runs/:runId/cancel", async (req, res) => {
|
router.post("/heartbeat-runs/:runId/cancel", async (req, res) => {
|
||||||
assertBoard(req);
|
assertBoard(req);
|
||||||
const runId = req.params.runId as string;
|
const runId = req.params.runId as string;
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import type { Db } from "@paperclipai/db";
|
|||||||
import { and, eq, sql } from "drizzle-orm";
|
import { and, eq, sql } from "drizzle-orm";
|
||||||
import { joinRequests } from "@paperclipai/db";
|
import { joinRequests } from "@paperclipai/db";
|
||||||
import { sidebarBadgeService } from "../services/sidebar-badges.js";
|
import { sidebarBadgeService } from "../services/sidebar-badges.js";
|
||||||
import { issueService } from "../services/issues.js";
|
|
||||||
import { accessService } from "../services/access.js";
|
import { accessService } from "../services/access.js";
|
||||||
import { dashboardService } from "../services/dashboard.js";
|
import { dashboardService } from "../services/dashboard.js";
|
||||||
import { assertCompanyAccess } from "./authz.js";
|
import { assertCompanyAccess } from "./authz.js";
|
||||||
@@ -11,7 +10,6 @@ import { assertCompanyAccess } from "./authz.js";
|
|||||||
export function sidebarBadgeRoutes(db: Db) {
|
export function sidebarBadgeRoutes(db: Db) {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const svc = sidebarBadgeService(db);
|
const svc = sidebarBadgeService(db);
|
||||||
const issueSvc = issueService(db);
|
|
||||||
const access = accessService(db);
|
const access = accessService(db);
|
||||||
const dashboard = dashboardService(db);
|
const dashboard = dashboardService(db);
|
||||||
|
|
||||||
@@ -40,12 +38,11 @@ export function sidebarBadgeRoutes(db: Db) {
|
|||||||
joinRequests: joinRequestCount,
|
joinRequests: joinRequestCount,
|
||||||
});
|
});
|
||||||
const summary = await dashboard.summary(companyId);
|
const summary = await dashboard.summary(companyId);
|
||||||
const staleIssueCount = await issueSvc.staleCount(companyId, 24 * 60);
|
|
||||||
const hasFailedRuns = badges.failedRuns > 0;
|
const hasFailedRuns = badges.failedRuns > 0;
|
||||||
const alertsCount =
|
const alertsCount =
|
||||||
(summary.agents.error > 0 && !hasFailedRuns ? 1 : 0) +
|
(summary.agents.error > 0 && !hasFailedRuns ? 1 : 0) +
|
||||||
(summary.costs.monthBudgetCents > 0 && summary.costs.monthUtilizationPercent >= 80 ? 1 : 0);
|
(summary.costs.monthBudgetCents > 0 && summary.costs.monthUtilizationPercent >= 80 ? 1 : 0);
|
||||||
badges.inbox = badges.failedRuns + alertsCount + staleIssueCount + joinRequestCount + badges.approvals;
|
badges.inbox = badges.failedRuns + alertsCount + joinRequestCount + badges.approvals;
|
||||||
|
|
||||||
res.json(badges);
|
res.json(badges);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -32,19 +32,6 @@ export function dashboardService(db: Db) {
|
|||||||
.where(and(eq(approvals.companyId, companyId), eq(approvals.status, "pending")))
|
.where(and(eq(approvals.companyId, companyId), eq(approvals.status, "pending")))
|
||||||
.then((rows) => Number(rows[0]?.count ?? 0));
|
.then((rows) => Number(rows[0]?.count ?? 0));
|
||||||
|
|
||||||
const staleCutoff = new Date(Date.now() - 60 * 60 * 1000);
|
|
||||||
const staleTasks = await db
|
|
||||||
.select({ count: sql<number>`count(*)` })
|
|
||||||
.from(issues)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(issues.companyId, companyId),
|
|
||||||
eq(issues.status, "in_progress"),
|
|
||||||
sql`${issues.startedAt} < ${staleCutoff.toISOString()}`,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.then((rows) => Number(rows[0]?.count ?? 0));
|
|
||||||
|
|
||||||
const agentCounts: Record<string, number> = {
|
const agentCounts: Record<string, number> = {
|
||||||
active: 0,
|
active: 0,
|
||||||
running: 0,
|
running: 0,
|
||||||
@@ -107,7 +94,6 @@ export function dashboardService(db: Db) {
|
|||||||
monthUtilizationPercent: Number(utilization.toFixed(2)),
|
monthUtilizationPercent: Number(utilization.toFixed(2)),
|
||||||
},
|
},
|
||||||
pendingApprovals,
|
pendingApprovals,
|
||||||
staleTasks,
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -46,6 +46,69 @@ const DEFERRED_WAKE_CONTEXT_KEY = "_paperclipWakeContext";
|
|||||||
const startLocksByAgent = new Map<string, Promise<void>>();
|
const startLocksByAgent = new Map<string, Promise<void>>();
|
||||||
const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__";
|
const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__";
|
||||||
|
|
||||||
|
const summarizedHeartbeatRunResultJson = sql<Record<string, unknown> | null>`
|
||||||
|
CASE
|
||||||
|
WHEN ${heartbeatRuns.resultJson} IS NULL THEN NULL
|
||||||
|
ELSE NULLIF(
|
||||||
|
jsonb_strip_nulls(
|
||||||
|
jsonb_build_object(
|
||||||
|
'summary', CASE
|
||||||
|
WHEN ${heartbeatRuns.resultJson} ->> 'summary' IS NULL THEN NULL
|
||||||
|
ELSE left(${heartbeatRuns.resultJson} ->> 'summary', 500)
|
||||||
|
END,
|
||||||
|
'result', CASE
|
||||||
|
WHEN ${heartbeatRuns.resultJson} ->> 'result' IS NULL THEN NULL
|
||||||
|
ELSE left(${heartbeatRuns.resultJson} ->> 'result', 500)
|
||||||
|
END,
|
||||||
|
'message', CASE
|
||||||
|
WHEN ${heartbeatRuns.resultJson} ->> 'message' IS NULL THEN NULL
|
||||||
|
ELSE left(${heartbeatRuns.resultJson} ->> 'message', 500)
|
||||||
|
END,
|
||||||
|
'error', CASE
|
||||||
|
WHEN ${heartbeatRuns.resultJson} ->> 'error' IS NULL THEN NULL
|
||||||
|
ELSE left(${heartbeatRuns.resultJson} ->> 'error', 500)
|
||||||
|
END,
|
||||||
|
'total_cost_usd', ${heartbeatRuns.resultJson} -> 'total_cost_usd',
|
||||||
|
'cost_usd', ${heartbeatRuns.resultJson} -> 'cost_usd',
|
||||||
|
'costUsd', ${heartbeatRuns.resultJson} -> 'costUsd'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
'{}'::jsonb
|
||||||
|
)
|
||||||
|
END
|
||||||
|
`;
|
||||||
|
|
||||||
|
const heartbeatRunListColumns = {
|
||||||
|
id: heartbeatRuns.id,
|
||||||
|
companyId: heartbeatRuns.companyId,
|
||||||
|
agentId: heartbeatRuns.agentId,
|
||||||
|
invocationSource: heartbeatRuns.invocationSource,
|
||||||
|
triggerDetail: heartbeatRuns.triggerDetail,
|
||||||
|
status: heartbeatRuns.status,
|
||||||
|
startedAt: heartbeatRuns.startedAt,
|
||||||
|
finishedAt: heartbeatRuns.finishedAt,
|
||||||
|
error: heartbeatRuns.error,
|
||||||
|
wakeupRequestId: heartbeatRuns.wakeupRequestId,
|
||||||
|
exitCode: heartbeatRuns.exitCode,
|
||||||
|
signal: heartbeatRuns.signal,
|
||||||
|
usageJson: heartbeatRuns.usageJson,
|
||||||
|
resultJson: summarizedHeartbeatRunResultJson.as("resultJson"),
|
||||||
|
sessionIdBefore: heartbeatRuns.sessionIdBefore,
|
||||||
|
sessionIdAfter: heartbeatRuns.sessionIdAfter,
|
||||||
|
logStore: heartbeatRuns.logStore,
|
||||||
|
logRef: heartbeatRuns.logRef,
|
||||||
|
logBytes: heartbeatRuns.logBytes,
|
||||||
|
logSha256: heartbeatRuns.logSha256,
|
||||||
|
logCompressed: heartbeatRuns.logCompressed,
|
||||||
|
stdoutExcerpt: sql<string | null>`NULL`.as("stdoutExcerpt"),
|
||||||
|
stderrExcerpt: sql<string | null>`NULL`.as("stderrExcerpt"),
|
||||||
|
errorCode: heartbeatRuns.errorCode,
|
||||||
|
externalRunId: heartbeatRuns.externalRunId,
|
||||||
|
contextSnapshot: heartbeatRuns.contextSnapshot,
|
||||||
|
createdAt: heartbeatRuns.createdAt,
|
||||||
|
updatedAt: heartbeatRuns.updatedAt,
|
||||||
|
} as const;
|
||||||
|
|
||||||
function appendExcerpt(prev: string, chunk: string) {
|
function appendExcerpt(prev: string, chunk: string) {
|
||||||
return appendWithCap(prev, chunk, MAX_EXCERPT_BYTES);
|
return appendWithCap(prev, chunk, MAX_EXCERPT_BYTES);
|
||||||
}
|
}
|
||||||
@@ -2260,9 +2323,9 @@ export function heartbeatService(db: Db) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
list: (companyId: string, agentId?: string, limit?: number) => {
|
list: async (companyId: string, agentId?: string, limit?: number) => {
|
||||||
const query = db
|
const query = db
|
||||||
.select()
|
.select(heartbeatRunListColumns)
|
||||||
.from(heartbeatRuns)
|
.from(heartbeatRuns)
|
||||||
.where(
|
.where(
|
||||||
agentId
|
agentId
|
||||||
|
|||||||
@@ -1411,23 +1411,5 @@ export function issueService(db: Db) {
|
|||||||
goal: a.goalId ? goalMap.get(a.goalId) ?? null : null,
|
goal: a.goalId ? goalMap.get(a.goalId) ?? null : null,
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
staleCount: async (companyId: string, minutes = 60) => {
|
|
||||||
const cutoff = new Date(Date.now() - minutes * 60 * 1000);
|
|
||||||
const result = await db
|
|
||||||
.select({ count: sql<number>`count(*)` })
|
|
||||||
.from(issues)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(issues.companyId, companyId),
|
|
||||||
eq(issues.status, "in_progress"),
|
|
||||||
isNull(issues.hiddenAt),
|
|
||||||
sql`${issues.startedAt} < ${cutoff.toISOString()}`,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.then((rows) => rows[0]);
|
|
||||||
|
|
||||||
return Number(result?.count ?? 0);
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import { NotFoundPage } from "./pages/NotFound";
|
|||||||
import { queryKeys } from "./lib/queryKeys";
|
import { queryKeys } from "./lib/queryKeys";
|
||||||
import { useCompany } from "./context/CompanyContext";
|
import { useCompany } from "./context/CompanyContext";
|
||||||
import { useDialog } from "./context/DialogContext";
|
import { useDialog } from "./context/DialogContext";
|
||||||
|
import { loadLastInboxTab } from "./lib/inbox";
|
||||||
|
|
||||||
function BootstrapPendingPage({ hasActiveInvite = false }: { hasActiveInvite?: boolean }) {
|
function BootstrapPendingPage({ hasActiveInvite = false }: { hasActiveInvite?: boolean }) {
|
||||||
return (
|
return (
|
||||||
@@ -138,15 +139,21 @@ function boardRoutes() {
|
|||||||
<Route path="approvals/:approvalId" element={<ApprovalDetail />} />
|
<Route path="approvals/:approvalId" element={<ApprovalDetail />} />
|
||||||
<Route path="costs" element={<Costs />} />
|
<Route path="costs" element={<Costs />} />
|
||||||
<Route path="activity" element={<Activity />} />
|
<Route path="activity" element={<Activity />} />
|
||||||
<Route path="inbox" element={<Navigate to="/inbox/new" replace />} />
|
<Route path="inbox" element={<InboxRootRedirect />} />
|
||||||
<Route path="inbox/new" element={<Inbox />} />
|
<Route path="inbox/recent" element={<Inbox />} />
|
||||||
|
<Route path="inbox/unread" element={<Inbox />} />
|
||||||
<Route path="inbox/all" element={<Inbox />} />
|
<Route path="inbox/all" element={<Inbox />} />
|
||||||
|
<Route path="inbox/new" element={<Navigate to="/inbox/recent" replace />} />
|
||||||
<Route path="design-guide" element={<DesignGuide />} />
|
<Route path="design-guide" element={<DesignGuide />} />
|
||||||
<Route path="*" element={<NotFoundPage scope="board" />} />
|
<Route path="*" element={<NotFoundPage scope="board" />} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function InboxRootRedirect() {
|
||||||
|
return <Navigate to={`/inbox/${loadLastInboxTab()}`} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
function CompanyRootRedirect() {
|
function CompanyRootRedirect() {
|
||||||
const { companies, selectedCompany, loading } = useCompany();
|
const { companies, selectedCompany, loading } = useCompany();
|
||||||
const { onboardingOpen } = useDialog();
|
const { onboardingOpen } = useDialog();
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export const heartbeatsApi = {
|
|||||||
const qs = searchParams.toString();
|
const qs = searchParams.toString();
|
||||||
return api.get<HeartbeatRun[]>(`/companies/${companyId}/heartbeat-runs${qs ? `?${qs}` : ""}`);
|
return api.get<HeartbeatRun[]>(`/companies/${companyId}/heartbeat-runs${qs ? `?${qs}` : ""}`);
|
||||||
},
|
},
|
||||||
|
get: (runId: string) => api.get<HeartbeatRun>(`/heartbeat-runs/${runId}`),
|
||||||
events: (runId: string, afterSeq = 0, limit = 200) =>
|
events: (runId: string, afterSeq = 0, limit = 200) =>
|
||||||
api.get<HeartbeatRunEvent[]>(
|
api.get<HeartbeatRunEvent[]>(
|
||||||
`/heartbeat-runs/${runId}/events?afterSeq=${encodeURIComponent(String(afterSeq))}&limit=${encodeURIComponent(String(limit))}`,
|
`/heartbeat-runs/${runId}/events?afterSeq=${encodeURIComponent(String(afterSeq))}&limit=${encodeURIComponent(String(limit))}`,
|
||||||
|
|||||||
@@ -434,7 +434,7 @@ function AgentRunCard({
|
|||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
{isActive ? (
|
{isActive ? (
|
||||||
<span className="relative flex h-2 w-2 shrink-0">
|
<span className="relative flex h-2 w-2 shrink-0">
|
||||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
|
<span className="animate-pulse 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 className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useRef, useMemo } from "react";
|
import { useState, useEffect, useRef, useMemo, useCallback } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { AGENT_ADAPTER_TYPES } from "@paperclipai/shared";
|
import { AGENT_ADAPTER_TYPES } from "@paperclipai/shared";
|
||||||
import type {
|
import type {
|
||||||
@@ -221,7 +221,11 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Build accumulated patch and send to parent */
|
/** Build accumulated patch and send to parent */
|
||||||
function handleSave() {
|
const handleCancel = useCallback(() => {
|
||||||
|
setOverlay({ ...emptyOverlay });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSave = useCallback(() => {
|
||||||
if (isCreate || !isDirty) return;
|
if (isCreate || !isDirty) return;
|
||||||
const agent = props.agent;
|
const agent = props.agent;
|
||||||
const patch: Record<string, unknown> = {};
|
const patch: Record<string, unknown> = {};
|
||||||
@@ -248,21 +252,24 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
props.onSave(patch);
|
props.onSave(patch);
|
||||||
}
|
}, [isCreate, isDirty, overlay, props]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isCreate) {
|
if (!isCreate) {
|
||||||
props.onDirtyChange?.(isDirty);
|
props.onDirtyChange?.(isDirty);
|
||||||
props.onSaveActionChange?.(() => handleSave());
|
props.onSaveActionChange?.(handleSave);
|
||||||
props.onCancelActionChange?.(() => setOverlay({ ...emptyOverlay }));
|
props.onCancelActionChange?.(handleCancel);
|
||||||
return () => {
|
|
||||||
props.onSaveActionChange?.(null);
|
|
||||||
props.onCancelActionChange?.(null);
|
|
||||||
props.onDirtyChange?.(false);
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
return;
|
}, [isCreate, isDirty, props.onDirtyChange, props.onSaveActionChange, props.onCancelActionChange, handleSave, handleCancel]);
|
||||||
}, [isCreate, isDirty, props.onDirtyChange, props.onSaveActionChange, props.onCancelActionChange, overlay]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isCreate) return;
|
||||||
|
return () => {
|
||||||
|
props.onSaveActionChange?.(null);
|
||||||
|
props.onCancelActionChange?.(null);
|
||||||
|
props.onDirtyChange?.(false);
|
||||||
|
};
|
||||||
|
}, [isCreate, props.onDirtyChange, props.onSaveActionChange, props.onCancelActionChange]);
|
||||||
|
|
||||||
// ---- Resolve values ----
|
// ---- Resolve values ----
|
||||||
const config = !isCreate ? ((props.agent.adapterConfig ?? {}) as Record<string, unknown>) : {};
|
const config = !isCreate ? ((props.agent.adapterConfig ?? {}) as Record<string, unknown>) : {};
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ function SortableCompanyItem({
|
|||||||
{hasLiveAgents && (
|
{hasLiveAgents && (
|
||||||
<span className="pointer-events-none absolute -right-0.5 -top-0.5 z-10">
|
<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="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="absolute inline-flex h-full w-full animate-pulse 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 className="relative inline-flex h-2.5 w-2.5 rounded-full bg-blue-500 ring-2 ring-background" />
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ interface IssuesListProps {
|
|||||||
liveIssueIds?: Set<string>;
|
liveIssueIds?: Set<string>;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
viewStateKey: string;
|
viewStateKey: string;
|
||||||
|
issueLinkState?: unknown;
|
||||||
initialAssignees?: string[];
|
initialAssignees?: string[];
|
||||||
initialSearch?: string;
|
initialSearch?: string;
|
||||||
onSearchChange?: (search: string) => void;
|
onSearchChange?: (search: string) => void;
|
||||||
@@ -156,6 +157,7 @@ export function IssuesList({
|
|||||||
liveIssueIds,
|
liveIssueIds,
|
||||||
projectId,
|
projectId,
|
||||||
viewStateKey,
|
viewStateKey,
|
||||||
|
issueLinkState,
|
||||||
initialAssignees,
|
initialAssignees,
|
||||||
initialSearch,
|
initialSearch,
|
||||||
onSearchChange,
|
onSearchChange,
|
||||||
@@ -591,6 +593,7 @@ export function IssuesList({
|
|||||||
<Link
|
<Link
|
||||||
key={issue.id}
|
key={issue.id}
|
||||||
to={`/issues/${issue.identifier ?? issue.id}`}
|
to={`/issues/${issue.identifier ?? issue.id}`}
|
||||||
|
state={issueLinkState}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
{/* Status icon - left column on mobile, inline on desktop */}
|
{/* Status icon - left column on mobile, inline on desktop */}
|
||||||
@@ -625,7 +628,7 @@ export function IssuesList({
|
|||||||
{liveIssueIds?.has(issue.id) && (
|
{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="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="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="animate-pulse 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 className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[11px] font-medium text-blue-600 dark:text-blue-400 hidden sm:inline">Live</span>
|
<span className="text-[11px] font-medium text-blue-600 dark:text-blue-400 hidden sm:inline">Live</span>
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ function KanbanCard({
|
|||||||
</span>
|
</span>
|
||||||
{isLive && (
|
{isLive && (
|
||||||
<span className="relative flex h-2 w-2 shrink-0 mt-0.5">
|
<span className="relative flex h-2 w-2 shrink-0 mt-0.5">
|
||||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
|
<span className="animate-pulse 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 className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState, type UIEvent } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { BookOpen, Moon, Sun } from "lucide-react";
|
import { BookOpen, Moon, Sun } from "lucide-react";
|
||||||
import { Outlet, useLocation, useNavigate, useParams } from "@/lib/router";
|
import { Outlet, useLocation, useNavigate, useParams } from "@/lib/router";
|
||||||
@@ -177,28 +177,56 @@ export function Layout() {
|
|||||||
};
|
};
|
||||||
}, [isMobile, sidebarOpen, setSidebarOpen]);
|
}, [isMobile, sidebarOpen, setSidebarOpen]);
|
||||||
|
|
||||||
const handleMainScroll = useCallback(
|
const updateMobileNavVisibility = useCallback((currentTop: number) => {
|
||||||
(event: UIEvent<HTMLElement>) => {
|
const delta = currentTop - lastMainScrollTop.current;
|
||||||
if (!isMobile) return;
|
|
||||||
|
|
||||||
const currentTop = event.currentTarget.scrollTop;
|
if (currentTop <= 24) {
|
||||||
const delta = currentTop - lastMainScrollTop.current;
|
setMobileNavVisible(true);
|
||||||
|
} else if (delta > 8) {
|
||||||
|
setMobileNavVisible(false);
|
||||||
|
} else if (delta < -8) {
|
||||||
|
setMobileNavVisible(true);
|
||||||
|
}
|
||||||
|
|
||||||
if (currentTop <= 24) {
|
lastMainScrollTop.current = currentTop;
|
||||||
setMobileNavVisible(true);
|
}, []);
|
||||||
} else if (delta > 8) {
|
|
||||||
setMobileNavVisible(false);
|
|
||||||
} else if (delta < -8) {
|
|
||||||
setMobileNavVisible(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
lastMainScrollTop.current = currentTop;
|
useEffect(() => {
|
||||||
},
|
if (!isMobile) {
|
||||||
[isMobile],
|
setMobileNavVisible(true);
|
||||||
);
|
lastMainScrollTop.current = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const onScroll = () => {
|
||||||
|
updateMobileNavVisibility(window.scrollY || document.documentElement.scrollTop || 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
onScroll();
|
||||||
|
window.addEventListener("scroll", onScroll, { passive: true });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("scroll", onScroll);
|
||||||
|
};
|
||||||
|
}, [isMobile, updateMobileNavVisibility]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const previousOverflow = document.body.style.overflow;
|
||||||
|
|
||||||
|
document.body.style.overflow = isMobile ? "visible" : "hidden";
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = previousOverflow;
|
||||||
|
};
|
||||||
|
}, [isMobile]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-dvh bg-background text-foreground overflow-hidden pt-[env(safe-area-inset-top)]">
|
<div
|
||||||
|
className={cn(
|
||||||
|
"bg-background text-foreground pt-[env(safe-area-inset-top)]",
|
||||||
|
isMobile ? "min-h-dvh" : "flex h-dvh overflow-hidden",
|
||||||
|
)}
|
||||||
|
>
|
||||||
<a
|
<a
|
||||||
href="#main-content"
|
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"
|
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"
|
||||||
@@ -287,14 +315,22 @@ export function Layout() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
<div className="flex-1 flex flex-col min-w-0 h-full">
|
<div className={cn("flex min-w-0 flex-col", isMobile ? "w-full" : "h-full flex-1")}>
|
||||||
<BreadcrumbBar />
|
<div
|
||||||
<div className="flex flex-1 min-h-0">
|
className={cn(
|
||||||
|
isMobile && "sticky top-0 z-20 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/85",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<BreadcrumbBar />
|
||||||
|
</div>
|
||||||
|
<div className={cn(isMobile ? "block" : "flex flex-1 min-h-0")}>
|
||||||
<main
|
<main
|
||||||
id="main-content"
|
id="main-content"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
className={cn("flex-1 overflow-auto p-4 md:p-6", isMobile && "pb-[calc(5rem+env(safe-area-inset-bottom))]")}
|
className={cn(
|
||||||
onScroll={handleMainScroll}
|
"flex-1 p-4 md:p-6",
|
||||||
|
isMobile ? "overflow-visible pb-[calc(5rem+env(safe-area-inset-bottom))]" : "overflow-auto",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{hasUnknownCompanyPrefix ? (
|
{hasUnknownCompanyPrefix ? (
|
||||||
<NotFoundPage
|
<NotFoundPage
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ export function MarkdownBody({ children, className }: MarkdownBodyProps) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"prose prose-sm max-w-none prose-p:my-2 prose-p:leading-[1.4] prose-ul:my-1.5 prose-ol:my-1.5 prose-li:my-0.5 prose-li:leading-[1.4] prose-pre:my-2 prose-pre:whitespace-pre-wrap prose-pre:break-words prose-headings:my-2 prose-headings:text-sm prose-blockquote:leading-[1.4] prose-table:my-2 prose-th:px-3 prose-th:py-1.5 prose-td:px-3 prose-td:py-1.5 prose-code:break-all [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:list-decimal [&_ol]:pl-5 [&_li]:list-item",
|
"paperclip-markdown prose prose-sm max-w-none prose-pre:whitespace-pre-wrap prose-pre:break-words prose-code:break-all",
|
||||||
theme === "dark" && "prose-invert",
|
theme === "dark" && "prose-invert",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -566,7 +566,6 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
|||||||
"paperclip-mdxeditor-content focus:outline-none [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:list-decimal [&_ol]:pl-5 [&_li]:list-item",
|
"paperclip-mdxeditor-content focus:outline-none [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:list-decimal [&_ol]:pl-5 [&_li]:list-item",
|
||||||
contentClassName,
|
contentClassName,
|
||||||
)}
|
)}
|
||||||
overlayContainer={containerRef.current}
|
|
||||||
plugins={plugins}
|
plugins={plugins}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export function MetricCard({ icon: Icon, value, label, description, to, onClick
|
|||||||
<div className={`h-full px-4 py-4 sm:px-5 sm:py-5 rounded-lg transition-colors${isClickable ? " hover:bg-accent/50 cursor-pointer" : ""}`}>
|
<div className={`h-full px-4 py-4 sm:px-5 sm:py-5 rounded-lg transition-colors${isClickable ? " hover:bg-accent/50 cursor-pointer" : ""}`}>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-2xl sm:text-3xl font-semibold tracking-tight">
|
<p className="text-2xl sm:text-3xl font-semibold tracking-tight tabular-nums">
|
||||||
{value}
|
{value}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs sm:text-sm font-medium text-muted-foreground mt-1">
|
<p className="text-xs sm:text-sm font-medium text-muted-foreground mt-1">
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { NavLink, useLocation } from "@/lib/router";
|
import { NavLink, useLocation } from "@/lib/router";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import {
|
import {
|
||||||
House,
|
House,
|
||||||
CircleDot,
|
CircleDot,
|
||||||
@@ -8,11 +7,10 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
Inbox,
|
Inbox,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { sidebarBadgesApi } from "../api/sidebarBadges";
|
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { useDialog } from "../context/DialogContext";
|
import { useDialog } from "../context/DialogContext";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
|
import { useInboxBadge } from "../hooks/useInboxBadge";
|
||||||
|
|
||||||
interface MobileBottomNavProps {
|
interface MobileBottomNavProps {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
@@ -39,12 +37,7 @@ export function MobileBottomNav({ visible }: MobileBottomNavProps) {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { selectedCompanyId } = useCompany();
|
const { selectedCompanyId } = useCompany();
|
||||||
const { openNewIssue } = useDialog();
|
const { openNewIssue } = useDialog();
|
||||||
|
const inboxBadge = useInboxBadge(selectedCompanyId);
|
||||||
const { data: sidebarBadges } = useQuery({
|
|
||||||
queryKey: queryKeys.sidebarBadges(selectedCompanyId!),
|
|
||||||
queryFn: () => sidebarBadgesApi.get(selectedCompanyId!),
|
|
||||||
enabled: !!selectedCompanyId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const items = useMemo<MobileNavItem[]>(
|
const items = useMemo<MobileNavItem[]>(
|
||||||
() => [
|
() => [
|
||||||
@@ -57,10 +50,10 @@ export function MobileBottomNav({ visible }: MobileBottomNavProps) {
|
|||||||
to: "/inbox",
|
to: "/inbox",
|
||||||
label: "Inbox",
|
label: "Inbox",
|
||||||
icon: Inbox,
|
icon: Inbox,
|
||||||
badge: sidebarBadges?.inbox,
|
badge: inboxBadge.inbox,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[openNewIssue, sidebarBadges?.inbox],
|
[openNewIssue, inboxBadge.inbox],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
Tag,
|
Tag,
|
||||||
Calendar,
|
Calendar,
|
||||||
Paperclip,
|
Paperclip,
|
||||||
|
Loader2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { extractProviderIdWithFallback } from "../lib/model-utils";
|
import { extractProviderIdWithFallback } from "../lib/model-utils";
|
||||||
@@ -420,7 +421,7 @@ export function NewIssueDialog() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleSubmit() {
|
function handleSubmit() {
|
||||||
if (!effectiveCompanyId || !title.trim()) return;
|
if (!effectiveCompanyId || !title.trim() || createIssue.isPending) return;
|
||||||
const assigneeAdapterOverrides = buildAssigneeAdapterOverrides({
|
const assigneeAdapterOverrides = buildAssigneeAdapterOverrides({
|
||||||
adapterType: assigneeAdapterType,
|
adapterType: assigneeAdapterType,
|
||||||
modelOverride: assigneeModelOverride,
|
modelOverride: assigneeModelOverride,
|
||||||
@@ -516,6 +517,11 @@ export function NewIssueDialog() {
|
|||||||
})),
|
})),
|
||||||
[orderedProjects],
|
[orderedProjects],
|
||||||
);
|
);
|
||||||
|
const savedDraft = loadDraft();
|
||||||
|
const hasSavedDraft = Boolean(savedDraft?.title.trim() || savedDraft?.description.trim());
|
||||||
|
const canDiscardDraft = hasDraft || hasSavedDraft;
|
||||||
|
const createIssueErrorMessage =
|
||||||
|
createIssue.error instanceof Error ? createIssue.error.message : "Failed to create issue. Try again.";
|
||||||
|
|
||||||
const handleProjectChange = useCallback((nextProjectId: string) => {
|
const handleProjectChange = useCallback((nextProjectId: string) => {
|
||||||
setProjectId(nextProjectId);
|
setProjectId(nextProjectId);
|
||||||
@@ -563,7 +569,7 @@ export function NewIssueDialog() {
|
|||||||
<Dialog
|
<Dialog
|
||||||
open={newIssueOpen}
|
open={newIssueOpen}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) closeNewIssue();
|
if (!open && !createIssue.isPending) closeNewIssue();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
@@ -576,7 +582,16 @@ export function NewIssueDialog() {
|
|||||||
: "sm:max-w-lg"
|
: "sm:max-w-lg"
|
||||||
)}
|
)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
|
onEscapeKeyDown={(event) => {
|
||||||
|
if (createIssue.isPending) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}}
|
||||||
onPointerDownOutside={(event) => {
|
onPointerDownOutside={(event) => {
|
||||||
|
if (createIssue.isPending) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Radix Dialog's modal DismissableLayer calls preventDefault() on
|
// Radix Dialog's modal DismissableLayer calls preventDefault() on
|
||||||
// pointerdown events that originate outside the Dialog DOM tree.
|
// pointerdown events that originate outside the Dialog DOM tree.
|
||||||
// Popover portals render at the body level (outside the Dialog), so
|
// Popover portals render at the body level (outside the Dialog), so
|
||||||
@@ -654,6 +669,7 @@ export function NewIssueDialog() {
|
|||||||
size="icon-xs"
|
size="icon-xs"
|
||||||
className="text-muted-foreground"
|
className="text-muted-foreground"
|
||||||
onClick={() => setExpanded(!expanded)}
|
onClick={() => setExpanded(!expanded)}
|
||||||
|
disabled={createIssue.isPending}
|
||||||
>
|
>
|
||||||
{expanded ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />}
|
{expanded ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -662,6 +678,7 @@ export function NewIssueDialog() {
|
|||||||
size="icon-xs"
|
size="icon-xs"
|
||||||
className="text-muted-foreground"
|
className="text-muted-foreground"
|
||||||
onClick={() => closeNewIssue()}
|
onClick={() => closeNewIssue()}
|
||||||
|
disabled={createIssue.isPending}
|
||||||
>
|
>
|
||||||
<span className="text-lg leading-none">×</span>
|
<span className="text-lg leading-none">×</span>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -680,6 +697,7 @@ export function NewIssueDialog() {
|
|||||||
e.target.style.height = "auto";
|
e.target.style.height = "auto";
|
||||||
e.target.style.height = `${e.target.scrollHeight}px`;
|
e.target.style.height = `${e.target.scrollHeight}px`;
|
||||||
}}
|
}}
|
||||||
|
readOnly={createIssue.isPending}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter" && !e.metaKey && !e.ctrlKey) {
|
if (e.key === "Enter" && !e.metaKey && !e.ctrlKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -998,17 +1016,36 @@ export function NewIssueDialog() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
className="text-muted-foreground"
|
className="text-muted-foreground"
|
||||||
onClick={discardDraft}
|
onClick={discardDraft}
|
||||||
disabled={!hasDraft && !loadDraft()}
|
disabled={createIssue.isPending || !canDiscardDraft}
|
||||||
>
|
>
|
||||||
Discard Draft
|
Discard Draft
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<div className="flex items-center gap-3">
|
||||||
size="sm"
|
<div className="min-h-5 text-right">
|
||||||
disabled={!title.trim() || createIssue.isPending}
|
{createIssue.isPending ? (
|
||||||
onClick={handleSubmit}
|
<span className="inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||||
>
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
{createIssue.isPending ? "Creating..." : "Create Issue"}
|
Creating issue...
|
||||||
</Button>
|
</span>
|
||||||
|
) : createIssue.isError ? (
|
||||||
|
<span className="text-xs text-destructive">{createIssueErrorMessage}</span>
|
||||||
|
) : canDiscardDraft ? (
|
||||||
|
<span className="text-xs text-muted-foreground">Draft autosaves locally</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="min-w-[8.5rem] disabled:opacity-100"
|
||||||
|
disabled={!title.trim() || createIssue.isPending}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
aria-busy={createIssue.isPending}
|
||||||
|
>
|
||||||
|
<span className="inline-flex items-center justify-center gap-1.5">
|
||||||
|
{createIssue.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
|
||||||
|
<span>{createIssue.isPending ? "Creating..." : "Create Issue"}</span>
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -1,29 +1,68 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { ArrowDown } from "lucide-react";
|
import { ArrowDown } from "lucide-react";
|
||||||
|
|
||||||
|
function resolveScrollTarget() {
|
||||||
|
const mainContent = document.getElementById("main-content");
|
||||||
|
|
||||||
|
if (mainContent instanceof HTMLElement) {
|
||||||
|
const overflowY = window.getComputedStyle(mainContent).overflowY;
|
||||||
|
const usesOwnScroll =
|
||||||
|
(overflowY === "auto" || overflowY === "scroll" || overflowY === "overlay")
|
||||||
|
&& mainContent.scrollHeight > mainContent.clientHeight + 1;
|
||||||
|
|
||||||
|
if (usesOwnScroll) {
|
||||||
|
return { type: "element" as const, element: mainContent };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { type: "window" as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
function distanceFromBottom(target: ReturnType<typeof resolveScrollTarget>) {
|
||||||
|
if (target.type === "element") {
|
||||||
|
return target.element.scrollHeight - target.element.scrollTop - target.element.clientHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scroller = document.scrollingElement ?? document.documentElement;
|
||||||
|
return scroller.scrollHeight - window.scrollY - window.innerHeight;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Floating scroll-to-bottom button that appears when the user is far from the
|
* Floating scroll-to-bottom button that follows the active page scroller.
|
||||||
* bottom of the `#main-content` scroll container. Hides when within 300px of
|
* On desktop that is `#main-content`; on mobile it falls back to window/page scroll.
|
||||||
* the bottom. Positioned to avoid the mobile bottom nav.
|
|
||||||
*/
|
*/
|
||||||
export function ScrollToBottom() {
|
export function ScrollToBottom() {
|
||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = document.getElementById("main-content");
|
|
||||||
if (!el) return;
|
|
||||||
const check = () => {
|
const check = () => {
|
||||||
const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
|
setVisible(distanceFromBottom(resolveScrollTarget()) > 300);
|
||||||
setVisible(distance > 300);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mainContent = document.getElementById("main-content");
|
||||||
|
|
||||||
check();
|
check();
|
||||||
el.addEventListener("scroll", check, { passive: true });
|
mainContent?.addEventListener("scroll", check, { passive: true });
|
||||||
return () => el.removeEventListener("scroll", check);
|
window.addEventListener("scroll", check, { passive: true });
|
||||||
|
window.addEventListener("resize", check);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mainContent?.removeEventListener("scroll", check);
|
||||||
|
window.removeEventListener("scroll", check);
|
||||||
|
window.removeEventListener("resize", check);
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const scroll = useCallback(() => {
|
const scroll = useCallback(() => {
|
||||||
const el = document.getElementById("main-content");
|
const target = resolveScrollTarget();
|
||||||
if (el) el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
|
|
||||||
|
if (target.type === "element") {
|
||||||
|
target.element.scrollTo({ top: target.element.scrollHeight, behavior: "smooth" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scroller = document.scrollingElement ?? document.documentElement;
|
||||||
|
window.scrollTo({ top: scroller.scrollHeight, behavior: "smooth" });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (!visible) return null;
|
if (!visible) return null;
|
||||||
|
|||||||
@@ -17,19 +17,15 @@ import { SidebarProjects } from "./SidebarProjects";
|
|||||||
import { SidebarAgents } from "./SidebarAgents";
|
import { SidebarAgents } from "./SidebarAgents";
|
||||||
import { useDialog } from "../context/DialogContext";
|
import { useDialog } from "../context/DialogContext";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { sidebarBadgesApi } from "../api/sidebarBadges";
|
|
||||||
import { heartbeatsApi } from "../api/heartbeats";
|
import { heartbeatsApi } from "../api/heartbeats";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
|
import { useInboxBadge } from "../hooks/useInboxBadge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const { openNewIssue } = useDialog();
|
const { openNewIssue } = useDialog();
|
||||||
const { selectedCompanyId, selectedCompany } = useCompany();
|
const { selectedCompanyId, selectedCompany } = useCompany();
|
||||||
const { data: sidebarBadges } = useQuery({
|
const inboxBadge = useInboxBadge(selectedCompanyId);
|
||||||
queryKey: queryKeys.sidebarBadges(selectedCompanyId!),
|
|
||||||
queryFn: () => sidebarBadgesApi.get(selectedCompanyId!),
|
|
||||||
enabled: !!selectedCompanyId,
|
|
||||||
});
|
|
||||||
const { data: liveRuns } = useQuery({
|
const { data: liveRuns } = useQuery({
|
||||||
queryKey: queryKeys.liveRuns(selectedCompanyId!),
|
queryKey: queryKeys.liveRuns(selectedCompanyId!),
|
||||||
queryFn: () => heartbeatsApi.liveRunsForCompany(selectedCompanyId!),
|
queryFn: () => heartbeatsApi.liveRunsForCompany(selectedCompanyId!),
|
||||||
@@ -80,9 +76,9 @@ export function Sidebar() {
|
|||||||
to="/inbox"
|
to="/inbox"
|
||||||
label="Inbox"
|
label="Inbox"
|
||||||
icon={Inbox}
|
icon={Inbox}
|
||||||
badge={sidebarBadges?.inbox}
|
badge={inboxBadge.inbox}
|
||||||
badgeTone={sidebarBadges?.failedRuns ? "danger" : "default"}
|
badgeTone={inboxBadge.failedRuns > 0 ? "danger" : "default"}
|
||||||
alert={(sidebarBadges?.failedRuns ?? 0) > 0}
|
alert={inboxBadge.failedRuns > 0}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ export function SidebarAgents() {
|
|||||||
{runCount > 0 && (
|
{runCount > 0 && (
|
||||||
<span className="ml-auto flex items-center gap-1.5 shrink-0">
|
<span className="ml-auto flex items-center gap-1.5 shrink-0">
|
||||||
<span className="relative flex h-2 w-2">
|
<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="animate-pulse 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 className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[11px] font-medium text-blue-600 dark:text-blue-400">
|
<span className="text-[11px] font-medium text-blue-600 dark:text-blue-400">
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export function SidebarNavItem({
|
|||||||
{liveCount != null && liveCount > 0 && (
|
{liveCount != null && liveCount > 0 && (
|
||||||
<span className="ml-auto flex items-center gap-1.5">
|
<span className="ml-auto flex items-center gap-1.5">
|
||||||
<span className="relative flex h-2 w-2">
|
<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="animate-pulse 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 className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[11px] font-medium text-blue-600 dark:text-blue-400">{liveCount} live</span>
|
<span className="text-[11px] font-medium text-blue-600 dark:text-blue-400">{liveCount} live</span>
|
||||||
|
|||||||
@@ -256,7 +256,7 @@ function buildJoinRequestToast(
|
|||||||
title: `${label} wants to join`,
|
title: `${label} wants to join`,
|
||||||
body: "A new join request is waiting for approval.",
|
body: "A new join request is waiting for approval.",
|
||||||
tone: "info",
|
tone: "info",
|
||||||
action: { label: "View inbox", href: "/inbox/new" },
|
action: { label: "View inbox", href: "/inbox/unread" },
|
||||||
dedupeKey: `join-request:${entityId}`,
|
dedupeKey: `join-request:${entityId}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
108
ui/src/hooks/useInboxBadge.ts
Normal file
108
ui/src/hooks/useInboxBadge.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { accessApi } from "../api/access";
|
||||||
|
import { ApiError } from "../api/client";
|
||||||
|
import { approvalsApi } from "../api/approvals";
|
||||||
|
import { dashboardApi } from "../api/dashboard";
|
||||||
|
import { heartbeatsApi } from "../api/heartbeats";
|
||||||
|
import { issuesApi } from "../api/issues";
|
||||||
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
|
import {
|
||||||
|
computeInboxBadgeData,
|
||||||
|
getRecentTouchedIssues,
|
||||||
|
loadDismissedInboxItems,
|
||||||
|
saveDismissedInboxItems,
|
||||||
|
getUnreadTouchedIssues,
|
||||||
|
} from "../lib/inbox";
|
||||||
|
|
||||||
|
const INBOX_ISSUE_STATUSES = "backlog,todo,in_progress,in_review,blocked,done";
|
||||||
|
|
||||||
|
export function useDismissedInboxItems() {
|
||||||
|
const [dismissed, setDismissed] = useState<Set<string>>(loadDismissedInboxItems);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleStorage = (event: StorageEvent) => {
|
||||||
|
if (event.key !== "paperclip:inbox:dismissed") return;
|
||||||
|
setDismissed(loadDismissedInboxItems());
|
||||||
|
};
|
||||||
|
window.addEventListener("storage", handleStorage);
|
||||||
|
return () => window.removeEventListener("storage", handleStorage);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const dismiss = (id: string) => {
|
||||||
|
setDismissed((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.add(id);
|
||||||
|
saveDismissedInboxItems(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return { dismissed, dismiss };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useInboxBadge(companyId: string | null | undefined) {
|
||||||
|
const { dismissed } = useDismissedInboxItems();
|
||||||
|
|
||||||
|
const { data: approvals = [] } = useQuery({
|
||||||
|
queryKey: queryKeys.approvals.list(companyId!),
|
||||||
|
queryFn: () => approvalsApi.list(companyId!),
|
||||||
|
enabled: !!companyId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: joinRequests = [] } = useQuery({
|
||||||
|
queryKey: queryKeys.access.joinRequests(companyId!),
|
||||||
|
queryFn: async () => {
|
||||||
|
try {
|
||||||
|
return await accessApi.listJoinRequests(companyId!, "pending_approval");
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError && (err.status === 401 || err.status === 403)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled: !!companyId,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: dashboard } = useQuery({
|
||||||
|
queryKey: queryKeys.dashboard(companyId!),
|
||||||
|
queryFn: () => dashboardApi.summary(companyId!),
|
||||||
|
enabled: !!companyId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: touchedIssues = [] } = useQuery({
|
||||||
|
queryKey: queryKeys.issues.listTouchedByMe(companyId!),
|
||||||
|
queryFn: () =>
|
||||||
|
issuesApi.list(companyId!, {
|
||||||
|
touchedByUserId: "me",
|
||||||
|
status: INBOX_ISSUE_STATUSES,
|
||||||
|
}),
|
||||||
|
enabled: !!companyId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const unreadIssues = useMemo(
|
||||||
|
() => getUnreadTouchedIssues(getRecentTouchedIssues(touchedIssues)),
|
||||||
|
[touchedIssues],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: heartbeatRuns = [] } = useQuery({
|
||||||
|
queryKey: queryKeys.heartbeats(companyId!),
|
||||||
|
queryFn: () => heartbeatsApi.list(companyId!),
|
||||||
|
enabled: !!companyId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() =>
|
||||||
|
computeInboxBadgeData({
|
||||||
|
approvals,
|
||||||
|
joinRequests,
|
||||||
|
dashboard,
|
||||||
|
heartbeatRuns,
|
||||||
|
unreadIssues,
|
||||||
|
dismissed,
|
||||||
|
}),
|
||||||
|
[approvals, joinRequests, dashboard, heartbeatRuns, unreadIssues, dismissed],
|
||||||
|
);
|
||||||
|
}
|
||||||
147
ui/src/index.css
147
ui/src/index.css
@@ -123,7 +123,7 @@
|
|||||||
-webkit-tap-highlight-color: color-mix(in oklab, var(--foreground) 20%, transparent);
|
-webkit-tap-highlight-color: color-mix(in oklab, var(--foreground) 20%, transparent);
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground antialiased;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@@ -426,6 +426,121 @@
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.paperclip-markdown {
|
||||||
|
color: var(--foreground);
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paperclip-markdown > :first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paperclip-markdown > :last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paperclip-markdown :where(p, ul, ol, blockquote, pre, table) {
|
||||||
|
margin-top: 0.7rem;
|
||||||
|
margin-bottom: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paperclip-markdown :where(ul, ol) {
|
||||||
|
padding-left: 1.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paperclip-markdown ul {
|
||||||
|
list-style-type: disc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paperclip-markdown ol {
|
||||||
|
list-style-type: decimal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paperclip-markdown li {
|
||||||
|
margin: 0.14rem 0;
|
||||||
|
padding-left: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paperclip-markdown li > :where(p, ul, ol) {
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paperclip-markdown li::marker {
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.paperclip-markdown :where(h1, h2, h3, h4) {
|
||||||
|
margin-top: 1.15rem;
|
||||||
|
margin-bottom: 0.45rem;
|
||||||
|
color: var(--foreground);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paperclip-markdown h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paperclip-markdown h2 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paperclip-markdown h3 {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paperclip-markdown h4 {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paperclip-markdown :where(strong, b) {
|
||||||
|
color: var(--foreground);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paperclip-markdown a {
|
||||||
|
color: color-mix(in oklab, var(--foreground) 76%, #0969da 24%);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paperclip-markdown a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 0.15em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .paperclip-markdown a {
|
||||||
|
color: color-mix(in oklab, var(--foreground) 80%, #58a6ff 20%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.paperclip-markdown blockquote {
|
||||||
|
margin-left: 0;
|
||||||
|
padding-left: 0.95rem;
|
||||||
|
border-left: 0.24rem solid color-mix(in oklab, var(--border) 84%, var(--muted-foreground) 16%);
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.paperclip-markdown hr {
|
||||||
|
margin: 1.25rem 0;
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.paperclip-markdown img {
|
||||||
|
border-radius: calc(var(--radius) + 2px);
|
||||||
|
box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--foreground) 10%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.paperclip-markdown table {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paperclip-markdown th {
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
.paperclip-mermaid {
|
.paperclip-mermaid {
|
||||||
margin: 0.5rem 0;
|
margin: 0.5rem 0;
|
||||||
padding: 0.45rem 0.55rem;
|
padding: 0.45rem 0.55rem;
|
||||||
@@ -476,25 +591,21 @@ a.paperclip-project-mention-chip {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Keep MDXEditor popups above app dialogs when editor is inside a modal. */
|
/* Keep MDXEditor popups above app dialogs, even when they portal to <body>. */
|
||||||
.paperclip-mdxeditor-scope [class*="_dialogOverlay_"],
|
[class*="_popupContainer_"] {
|
||||||
.paperclip-mdxeditor [class*="_dialogOverlay_"] {
|
z-index: 81 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[class*="_dialogOverlay_"] {
|
||||||
z-index: 80;
|
z-index: 80;
|
||||||
}
|
}
|
||||||
|
|
||||||
.paperclip-mdxeditor-scope [class*="_dialogContent_"],
|
[class*="_dialogContent_"],
|
||||||
.paperclip-mdxeditor-scope [class*="_largeDialogContent_"],
|
[class*="_largeDialogContent_"],
|
||||||
.paperclip-mdxeditor-scope [class*="_popoverContent_"],
|
[class*="_popoverContent_"],
|
||||||
.paperclip-mdxeditor-scope [class*="_linkDialogPopoverContent_"],
|
[class*="_linkDialogPopoverContent_"],
|
||||||
.paperclip-mdxeditor-scope [class*="_tableColumnEditorPopoverContent_"],
|
[class*="_tableColumnEditorPopoverContent_"],
|
||||||
.paperclip-mdxeditor-scope [class*="_toolbarButtonDropdownContainer_"],
|
[class*="_toolbarButtonDropdownContainer_"],
|
||||||
.paperclip-mdxeditor-scope [class*="_toolbarNodeKindSelectContainer_"],
|
[class*="_toolbarNodeKindSelectContainer_"] {
|
||||||
.paperclip-mdxeditor [class*="_dialogContent_"],
|
|
||||||
.paperclip-mdxeditor [class*="_largeDialogContent_"],
|
|
||||||
.paperclip-mdxeditor [class*="_popoverContent_"],
|
|
||||||
.paperclip-mdxeditor [class*="_linkDialogPopoverContent_"],
|
|
||||||
.paperclip-mdxeditor [class*="_tableColumnEditorPopoverContent_"],
|
|
||||||
.paperclip-mdxeditor [class*="_toolbarButtonDropdownContainer_"],
|
|
||||||
.paperclip-mdxeditor [class*="_toolbarNodeKindSelectContainer_"] {
|
|
||||||
z-index: 81 !important;
|
z-index: 81 !important;
|
||||||
}
|
}
|
||||||
|
|||||||
250
ui/src/lib/inbox.test.ts
Normal file
250
ui/src/lib/inbox.test.ts
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
// @vitest-environment node
|
||||||
|
|
||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import type { Approval, DashboardSummary, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared";
|
||||||
|
import {
|
||||||
|
computeInboxBadgeData,
|
||||||
|
getRecentTouchedIssues,
|
||||||
|
getUnreadTouchedIssues,
|
||||||
|
loadLastInboxTab,
|
||||||
|
RECENT_ISSUES_LIMIT,
|
||||||
|
saveLastInboxTab,
|
||||||
|
} from "./inbox";
|
||||||
|
|
||||||
|
const storage = new Map<string, string>();
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, "localStorage", {
|
||||||
|
value: {
|
||||||
|
getItem: (key: string) => storage.get(key) ?? null,
|
||||||
|
setItem: (key: string, value: string) => {
|
||||||
|
storage.set(key, value);
|
||||||
|
},
|
||||||
|
removeItem: (key: string) => {
|
||||||
|
storage.delete(key);
|
||||||
|
},
|
||||||
|
clear: () => {
|
||||||
|
storage.clear();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeApproval(status: Approval["status"]): Approval {
|
||||||
|
return {
|
||||||
|
id: `approval-${status}`,
|
||||||
|
companyId: "company-1",
|
||||||
|
type: "hire_agent",
|
||||||
|
requestedByAgentId: null,
|
||||||
|
requestedByUserId: null,
|
||||||
|
status,
|
||||||
|
payload: {},
|
||||||
|
decisionNote: null,
|
||||||
|
decidedByUserId: null,
|
||||||
|
decidedAt: null,
|
||||||
|
createdAt: new Date("2026-03-11T00:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-03-11T00:00:00.000Z"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeJoinRequest(id: string): JoinRequest {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
inviteId: "invite-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
requestType: "human",
|
||||||
|
status: "pending_approval",
|
||||||
|
requestEmailSnapshot: null,
|
||||||
|
requestIp: "127.0.0.1",
|
||||||
|
requestingUserId: null,
|
||||||
|
agentName: null,
|
||||||
|
adapterType: null,
|
||||||
|
capabilities: null,
|
||||||
|
agentDefaultsPayload: null,
|
||||||
|
claimSecretExpiresAt: null,
|
||||||
|
claimSecretConsumedAt: null,
|
||||||
|
createdAgentId: null,
|
||||||
|
approvedByUserId: null,
|
||||||
|
approvedAt: null,
|
||||||
|
rejectedByUserId: null,
|
||||||
|
rejectedAt: null,
|
||||||
|
createdAt: new Date("2026-03-11T00:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-03-11T00:00:00.000Z"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRun(id: string, status: HeartbeatRun["status"], createdAt: string, agentId = "agent-1"): HeartbeatRun {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
companyId: "company-1",
|
||||||
|
agentId,
|
||||||
|
invocationSource: "assignment",
|
||||||
|
triggerDetail: null,
|
||||||
|
status,
|
||||||
|
error: null,
|
||||||
|
wakeupRequestId: null,
|
||||||
|
exitCode: null,
|
||||||
|
signal: null,
|
||||||
|
usageJson: null,
|
||||||
|
resultJson: null,
|
||||||
|
sessionIdBefore: null,
|
||||||
|
sessionIdAfter: null,
|
||||||
|
logStore: null,
|
||||||
|
logRef: null,
|
||||||
|
logBytes: null,
|
||||||
|
logSha256: null,
|
||||||
|
logCompressed: false,
|
||||||
|
errorCode: null,
|
||||||
|
externalRunId: null,
|
||||||
|
stdoutExcerpt: null,
|
||||||
|
stderrExcerpt: null,
|
||||||
|
contextSnapshot: null,
|
||||||
|
startedAt: new Date(createdAt),
|
||||||
|
finishedAt: null,
|
||||||
|
createdAt: new Date(createdAt),
|
||||||
|
updatedAt: new Date(createdAt),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeIssue(id: string, isUnreadForMe: boolean): Issue {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
companyId: "company-1",
|
||||||
|
projectId: null,
|
||||||
|
goalId: null,
|
||||||
|
parentId: null,
|
||||||
|
title: `Issue ${id}`,
|
||||||
|
description: null,
|
||||||
|
status: "todo",
|
||||||
|
priority: "medium",
|
||||||
|
assigneeAgentId: null,
|
||||||
|
assigneeUserId: null,
|
||||||
|
createdByAgentId: null,
|
||||||
|
createdByUserId: null,
|
||||||
|
issueNumber: 1,
|
||||||
|
identifier: `PAP-${id}`,
|
||||||
|
requestDepth: 0,
|
||||||
|
billingCode: null,
|
||||||
|
assigneeAdapterOverrides: null,
|
||||||
|
executionWorkspaceSettings: null,
|
||||||
|
checkoutRunId: null,
|
||||||
|
executionRunId: null,
|
||||||
|
executionAgentNameKey: null,
|
||||||
|
executionLockedAt: null,
|
||||||
|
startedAt: null,
|
||||||
|
completedAt: null,
|
||||||
|
cancelledAt: null,
|
||||||
|
hiddenAt: null,
|
||||||
|
createdAt: new Date("2026-03-11T00:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-03-11T00:00:00.000Z"),
|
||||||
|
labels: [],
|
||||||
|
labelIds: [],
|
||||||
|
myLastTouchAt: new Date("2026-03-11T00:00:00.000Z"),
|
||||||
|
lastExternalCommentAt: new Date("2026-03-11T01:00:00.000Z"),
|
||||||
|
isUnreadForMe,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const dashboard: DashboardSummary = {
|
||||||
|
companyId: "company-1",
|
||||||
|
agents: {
|
||||||
|
active: 1,
|
||||||
|
running: 0,
|
||||||
|
paused: 0,
|
||||||
|
error: 1,
|
||||||
|
},
|
||||||
|
tasks: {
|
||||||
|
open: 1,
|
||||||
|
inProgress: 0,
|
||||||
|
blocked: 0,
|
||||||
|
done: 0,
|
||||||
|
},
|
||||||
|
costs: {
|
||||||
|
monthSpendCents: 900,
|
||||||
|
monthBudgetCents: 1000,
|
||||||
|
monthUtilizationPercent: 90,
|
||||||
|
},
|
||||||
|
pendingApprovals: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("inbox helpers", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
storage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts the same inbox sources the badge uses", () => {
|
||||||
|
const result = computeInboxBadgeData({
|
||||||
|
approvals: [makeApproval("pending"), makeApproval("approved")],
|
||||||
|
joinRequests: [makeJoinRequest("join-1")],
|
||||||
|
dashboard,
|
||||||
|
heartbeatRuns: [
|
||||||
|
makeRun("run-old", "failed", "2026-03-11T00:00:00.000Z"),
|
||||||
|
makeRun("run-latest", "timed_out", "2026-03-11T01:00:00.000Z"),
|
||||||
|
makeRun("run-other-agent", "failed", "2026-03-11T02:00:00.000Z", "agent-2"),
|
||||||
|
],
|
||||||
|
unreadIssues: [makeIssue("1", true)],
|
||||||
|
dismissed: new Set<string>(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
inbox: 6,
|
||||||
|
approvals: 1,
|
||||||
|
failedRuns: 2,
|
||||||
|
joinRequests: 1,
|
||||||
|
unreadTouchedIssues: 1,
|
||||||
|
alerts: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops dismissed runs and alerts from the computed badge", () => {
|
||||||
|
const result = computeInboxBadgeData({
|
||||||
|
approvals: [],
|
||||||
|
joinRequests: [],
|
||||||
|
dashboard,
|
||||||
|
heartbeatRuns: [makeRun("run-1", "failed", "2026-03-11T00:00:00.000Z")],
|
||||||
|
unreadIssues: [],
|
||||||
|
dismissed: new Set<string>(["run:run-1", "alert:budget", "alert:agent-errors"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
inbox: 0,
|
||||||
|
approvals: 0,
|
||||||
|
failedRuns: 0,
|
||||||
|
joinRequests: 0,
|
||||||
|
unreadTouchedIssues: 0,
|
||||||
|
alerts: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps read issues in the touched list but excludes them from unread counts", () => {
|
||||||
|
const issues = [makeIssue("1", true), makeIssue("2", false)];
|
||||||
|
|
||||||
|
expect(getUnreadTouchedIssues(issues).map((issue) => issue.id)).toEqual(["1"]);
|
||||||
|
expect(issues).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("limits recent touched issues before unread badge counting", () => {
|
||||||
|
const issues = Array.from({ length: RECENT_ISSUES_LIMIT + 5 }, (_, index) => {
|
||||||
|
const issue = makeIssue(String(index + 1), index < 3);
|
||||||
|
issue.lastExternalCommentAt = new Date(Date.UTC(2026, 2, 31, 0, 0, 0, 0) - index * 60_000);
|
||||||
|
return issue;
|
||||||
|
});
|
||||||
|
|
||||||
|
const recentIssues = getRecentTouchedIssues(issues);
|
||||||
|
|
||||||
|
expect(recentIssues).toHaveLength(RECENT_ISSUES_LIMIT);
|
||||||
|
expect(getUnreadTouchedIssues(recentIssues).map((issue) => issue.id)).toEqual(["1", "2", "3"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults the remembered inbox tab to recent and persists all", () => {
|
||||||
|
localStorage.clear();
|
||||||
|
expect(loadLastInboxTab()).toBe("recent");
|
||||||
|
|
||||||
|
saveLastInboxTab("all");
|
||||||
|
expect(loadLastInboxTab()).toBe("all");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps legacy new-tab storage to recent", () => {
|
||||||
|
localStorage.setItem("paperclip:inbox:last-tab", "new");
|
||||||
|
expect(loadLastInboxTab()).toBe("recent");
|
||||||
|
});
|
||||||
|
});
|
||||||
150
ui/src/lib/inbox.ts
Normal file
150
ui/src/lib/inbox.ts
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import type {
|
||||||
|
Approval,
|
||||||
|
DashboardSummary,
|
||||||
|
HeartbeatRun,
|
||||||
|
Issue,
|
||||||
|
JoinRequest,
|
||||||
|
} from "@paperclipai/shared";
|
||||||
|
|
||||||
|
export const RECENT_ISSUES_LIMIT = 100;
|
||||||
|
export const FAILED_RUN_STATUSES = new Set(["failed", "timed_out"]);
|
||||||
|
export const ACTIONABLE_APPROVAL_STATUSES = new Set(["pending", "revision_requested"]);
|
||||||
|
export const DISMISSED_KEY = "paperclip:inbox:dismissed";
|
||||||
|
export const INBOX_LAST_TAB_KEY = "paperclip:inbox:last-tab";
|
||||||
|
export type InboxTab = "recent" | "unread" | "all";
|
||||||
|
|
||||||
|
export interface InboxBadgeData {
|
||||||
|
inbox: number;
|
||||||
|
approvals: number;
|
||||||
|
failedRuns: number;
|
||||||
|
joinRequests: number;
|
||||||
|
unreadTouchedIssues: number;
|
||||||
|
alerts: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadDismissedInboxItems(): Set<string> {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(DISMISSED_KEY);
|
||||||
|
return raw ? new Set(JSON.parse(raw)) : new Set();
|
||||||
|
} catch {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveDismissedInboxItems(ids: Set<string>) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(DISMISSED_KEY, JSON.stringify([...ids]));
|
||||||
|
} catch {
|
||||||
|
// Ignore localStorage failures.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadLastInboxTab(): InboxTab {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(INBOX_LAST_TAB_KEY);
|
||||||
|
if (raw === "all" || raw === "unread" || raw === "recent") return raw;
|
||||||
|
if (raw === "new") return "recent";
|
||||||
|
return "recent";
|
||||||
|
} catch {
|
||||||
|
return "recent";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveLastInboxTab(tab: InboxTab) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(INBOX_LAST_TAB_KEY, tab);
|
||||||
|
} catch {
|
||||||
|
// Ignore localStorage failures.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLatestFailedRunsByAgent(runs: HeartbeatRun[]): HeartbeatRun[] {
|
||||||
|
const sorted = [...runs].sort(
|
||||||
|
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||||
|
);
|
||||||
|
const latestByAgent = new Map<string, HeartbeatRun>();
|
||||||
|
|
||||||
|
for (const run of sorted) {
|
||||||
|
if (!latestByAgent.has(run.agentId)) {
|
||||||
|
latestByAgent.set(run.agentId, run);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(latestByAgent.values()).filter((run) => FAILED_RUN_STATUSES.has(run.status));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeTimestamp(value: string | Date | null | undefined): number {
|
||||||
|
if (!value) return 0;
|
||||||
|
const timestamp = new Date(value).getTime();
|
||||||
|
return Number.isFinite(timestamp) ? timestamp : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function issueLastActivityTimestamp(issue: Issue): number {
|
||||||
|
const lastExternalCommentAt = normalizeTimestamp(issue.lastExternalCommentAt);
|
||||||
|
if (lastExternalCommentAt > 0) return lastExternalCommentAt;
|
||||||
|
|
||||||
|
const updatedAt = normalizeTimestamp(issue.updatedAt);
|
||||||
|
const myLastTouchAt = normalizeTimestamp(issue.myLastTouchAt);
|
||||||
|
if (myLastTouchAt > 0 && updatedAt <= myLastTouchAt) return 0;
|
||||||
|
|
||||||
|
return updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sortIssuesByMostRecentActivity(a: Issue, b: Issue): number {
|
||||||
|
const activityDiff = issueLastActivityTimestamp(b) - issueLastActivityTimestamp(a);
|
||||||
|
if (activityDiff !== 0) return activityDiff;
|
||||||
|
return normalizeTimestamp(b.updatedAt) - normalizeTimestamp(a.updatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRecentTouchedIssues(issues: Issue[]): Issue[] {
|
||||||
|
return [...issues].sort(sortIssuesByMostRecentActivity).slice(0, RECENT_ISSUES_LIMIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUnreadTouchedIssues(issues: Issue[]): Issue[] {
|
||||||
|
return issues.filter((issue) => issue.isUnreadForMe);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeInboxBadgeData({
|
||||||
|
approvals,
|
||||||
|
joinRequests,
|
||||||
|
dashboard,
|
||||||
|
heartbeatRuns,
|
||||||
|
unreadIssues,
|
||||||
|
dismissed,
|
||||||
|
}: {
|
||||||
|
approvals: Approval[];
|
||||||
|
joinRequests: JoinRequest[];
|
||||||
|
dashboard: DashboardSummary | undefined;
|
||||||
|
heartbeatRuns: HeartbeatRun[];
|
||||||
|
unreadIssues: Issue[];
|
||||||
|
dismissed: Set<string>;
|
||||||
|
}): InboxBadgeData {
|
||||||
|
const actionableApprovals = approvals.filter((approval) =>
|
||||||
|
ACTIONABLE_APPROVAL_STATUSES.has(approval.status),
|
||||||
|
).length;
|
||||||
|
const failedRuns = getLatestFailedRunsByAgent(heartbeatRuns).filter(
|
||||||
|
(run) => !dismissed.has(`run:${run.id}`),
|
||||||
|
).length;
|
||||||
|
const unreadTouchedIssues = unreadIssues.length;
|
||||||
|
const agentErrorCount = dashboard?.agents.error ?? 0;
|
||||||
|
const monthBudgetCents = dashboard?.costs.monthBudgetCents ?? 0;
|
||||||
|
const monthUtilizationPercent = dashboard?.costs.monthUtilizationPercent ?? 0;
|
||||||
|
const showAggregateAgentError =
|
||||||
|
agentErrorCount > 0 &&
|
||||||
|
failedRuns === 0 &&
|
||||||
|
!dismissed.has("alert:agent-errors");
|
||||||
|
const showBudgetAlert =
|
||||||
|
monthBudgetCents > 0 &&
|
||||||
|
monthUtilizationPercent >= 80 &&
|
||||||
|
!dismissed.has("alert:budget");
|
||||||
|
const alerts = Number(showAggregateAgentError) + Number(showBudgetAlert);
|
||||||
|
|
||||||
|
return {
|
||||||
|
inbox: actionableApprovals + joinRequests.length + failedRuns + unreadTouchedIssues + alerts,
|
||||||
|
approvals: actionableApprovals,
|
||||||
|
failedRuns,
|
||||||
|
joinRequests: joinRequests.length,
|
||||||
|
unreadTouchedIssues,
|
||||||
|
alerts,
|
||||||
|
};
|
||||||
|
}
|
||||||
24
ui/src/lib/issueDetailBreadcrumb.ts
Normal file
24
ui/src/lib/issueDetailBreadcrumb.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
type IssueDetailBreadcrumb = {
|
||||||
|
label: string;
|
||||||
|
href: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type IssueDetailLocationState = {
|
||||||
|
issueDetailBreadcrumb?: IssueDetailBreadcrumb;
|
||||||
|
};
|
||||||
|
|
||||||
|
function isIssueDetailBreadcrumb(value: unknown): value is IssueDetailBreadcrumb {
|
||||||
|
if (typeof value !== "object" || value === null) return false;
|
||||||
|
const candidate = value as Partial<IssueDetailBreadcrumb>;
|
||||||
|
return typeof candidate.label === "string" && typeof candidate.href === "string";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createIssueDetailLocationState(label: string, href: string): IssueDetailLocationState {
|
||||||
|
return { issueDetailBreadcrumb: { label, href } };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readIssueDetailBreadcrumb(state: unknown): IssueDetailBreadcrumb | null {
|
||||||
|
if (typeof state !== "object" || state === null) return null;
|
||||||
|
const candidate = (state as IssueDetailLocationState).issueDetailBreadcrumb;
|
||||||
|
return isIssueDetailBreadcrumb(candidate) ? candidate : null;
|
||||||
|
}
|
||||||
@@ -68,6 +68,7 @@ export const queryKeys = {
|
|||||||
["costs", companyId, from, to] as const,
|
["costs", companyId, from, to] as const,
|
||||||
heartbeats: (companyId: string, agentId?: string) =>
|
heartbeats: (companyId: string, agentId?: string) =>
|
||||||
["heartbeats", companyId, agentId] as const,
|
["heartbeats", companyId, agentId] as const,
|
||||||
|
runDetail: (runId: string) => ["heartbeat-run", runId] as const,
|
||||||
liveRuns: (companyId: string) => ["live-runs", companyId] as const,
|
liveRuns: (companyId: string) => ["live-runs", companyId] as const,
|
||||||
runIssues: (runId: string) => ["run-issues", runId] as const,
|
runIssues: (runId: string) => ["run-issues", runId] as const,
|
||||||
org: (companyId: string) => ["org", companyId] as const,
|
org: (companyId: string) => ["org", companyId] as const,
|
||||||
|
|||||||
@@ -311,7 +311,12 @@ export function AgentDetail() {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const canonicalTab = activeView === "configuration" ? "configuration" : "dashboard";
|
const canonicalTab =
|
||||||
|
activeView === "configuration"
|
||||||
|
? "configuration"
|
||||||
|
: activeView === "runs"
|
||||||
|
? "runs"
|
||||||
|
: "dashboard";
|
||||||
if (routeAgentRef !== canonicalAgentRef || urlTab !== canonicalTab) {
|
if (routeAgentRef !== canonicalAgentRef || urlTab !== canonicalTab) {
|
||||||
navigate(`/agents/${canonicalAgentRef}/${canonicalTab}`, { replace: true });
|
navigate(`/agents/${canonicalAgentRef}/${canonicalTab}`, { replace: true });
|
||||||
return;
|
return;
|
||||||
@@ -437,7 +442,7 @@ export function AgentDetail() {
|
|||||||
return <Navigate to={`/agents/${canonicalAgentRef}/dashboard`} replace />;
|
return <Navigate to={`/agents/${canonicalAgentRef}/dashboard`} replace />;
|
||||||
}
|
}
|
||||||
const isPendingApproval = agent.status === "pending_approval";
|
const isPendingApproval = agent.status === "pending_approval";
|
||||||
const showConfigActionBar = activeView === "configuration" && configDirty;
|
const showConfigActionBar = activeView === "configuration" && (configDirty || configSaving);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("space-y-6", isMobile && showConfigActionBar && "pb-24")}>
|
<div className={cn("space-y-6", isMobile && showConfigActionBar && "pb-24")}>
|
||||||
@@ -506,7 +511,7 @@ export function AgentDetail() {
|
|||||||
className="sm:hidden flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-blue-500/10 hover:bg-blue-500/20 transition-colors no-underline"
|
className="sm:hidden flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-blue-500/10 hover:bg-blue-500/20 transition-colors no-underline"
|
||||||
>
|
>
|
||||||
<span className="relative flex h-2 w-2">
|
<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="animate-pulse 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 className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[11px] font-medium text-blue-600 dark:text-blue-400">Live</span>
|
<span className="text-[11px] font-medium text-blue-600 dark:text-blue-400">Live</span>
|
||||||
@@ -558,15 +563,16 @@ export function AgentDetail() {
|
|||||||
|
|
||||||
{!urlRunId && (
|
{!urlRunId && (
|
||||||
<Tabs
|
<Tabs
|
||||||
value={activeView === "configuration" ? "configuration" : "dashboard"}
|
value={activeView}
|
||||||
onValueChange={(value) => navigate(`/agents/${canonicalAgentRef}/${value}`)}
|
onValueChange={(value) => navigate(`/agents/${canonicalAgentRef}/${value}`)}
|
||||||
>
|
>
|
||||||
<PageTabBar
|
<PageTabBar
|
||||||
items={[
|
items={[
|
||||||
{ value: "dashboard", label: "Dashboard" },
|
{ value: "dashboard", label: "Dashboard" },
|
||||||
{ value: "configuration", label: "Configuration" },
|
{ value: "configuration", label: "Configuration" },
|
||||||
|
{ value: "runs", label: "Runs" },
|
||||||
]}
|
]}
|
||||||
value={activeView === "configuration" ? "configuration" : "dashboard"}
|
value={activeView}
|
||||||
onValueChange={(value) => navigate(`/agents/${canonicalAgentRef}/${value}`)}
|
onValueChange={(value) => navigate(`/agents/${canonicalAgentRef}/${value}`)}
|
||||||
/>
|
/>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
@@ -707,7 +713,7 @@ function LatestRunCard({ runs, agentId }: { runs: HeartbeatRun[]; agentId: strin
|
|||||||
<h3 className="flex items-center gap-2 text-sm font-medium">
|
<h3 className="flex items-center gap-2 text-sm font-medium">
|
||||||
{isLive && (
|
{isLive && (
|
||||||
<span className="relative flex h-2 w-2">
|
<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="animate-pulse 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 className="relative inline-flex rounded-full h-2 w-2 bg-cyan-400" />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -851,7 +857,7 @@ function CostsSection({
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{runtimeState && (
|
{runtimeState && (
|
||||||
<div className="border border-border rounded-lg p-4">
|
<div className="border border-border rounded-lg p-4">
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 tabular-nums">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-xs text-muted-foreground block">Input tokens</span>
|
<span className="text-xs text-muted-foreground block">Input tokens</span>
|
||||||
<span className="text-lg font-semibold">{formatTokens(runtimeState.totalInputTokens)}</span>
|
<span className="text-lg font-semibold">{formatTokens(runtimeState.totalInputTokens)}</span>
|
||||||
@@ -890,9 +896,9 @@ function CostsSection({
|
|||||||
<tr key={run.id} className="border-b border-border last:border-b-0">
|
<tr key={run.id} className="border-b border-border last:border-b-0">
|
||||||
<td className="px-3 py-2">{formatDate(run.createdAt)}</td>
|
<td className="px-3 py-2">{formatDate(run.createdAt)}</td>
|
||||||
<td className="px-3 py-2 font-mono">{run.id.slice(0, 8)}</td>
|
<td className="px-3 py-2 font-mono">{run.id.slice(0, 8)}</td>
|
||||||
<td className="px-3 py-2 text-right">{formatTokens(Number(u.input_tokens ?? 0))}</td>
|
<td className="px-3 py-2 text-right tabular-nums">{formatTokens(Number(u.input_tokens ?? 0))}</td>
|
||||||
<td className="px-3 py-2 text-right">{formatTokens(Number(u.output_tokens ?? 0))}</td>
|
<td className="px-3 py-2 text-right tabular-nums">{formatTokens(Number(u.output_tokens ?? 0))}</td>
|
||||||
<td className="px-3 py-2 text-right">
|
<td className="px-3 py-2 text-right tabular-nums">
|
||||||
{(u.cost_usd || u.total_cost_usd)
|
{(u.cost_usd || u.total_cost_usd)
|
||||||
? `$${Number(u.cost_usd ?? u.total_cost_usd ?? 0).toFixed(4)}`
|
? `$${Number(u.cost_usd ?? u.total_cost_usd ?? 0).toFixed(4)}`
|
||||||
: "-"
|
: "-"
|
||||||
@@ -1037,6 +1043,8 @@ function ConfigurationTab({
|
|||||||
updatePermissions: { mutate: (canCreate: boolean) => void; isPending: boolean };
|
updatePermissions: { mutate: (canCreate: boolean) => void; isPending: boolean };
|
||||||
}) {
|
}) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const [awaitingRefreshAfterSave, setAwaitingRefreshAfterSave] = useState(false);
|
||||||
|
const lastAgentRef = useRef(agent);
|
||||||
|
|
||||||
const { data: adapterModels } = useQuery({
|
const { data: adapterModels } = useQuery({
|
||||||
queryKey:
|
queryKey:
|
||||||
@@ -1049,16 +1057,31 @@ function ConfigurationTab({
|
|||||||
|
|
||||||
const updateAgent = useMutation({
|
const updateAgent = useMutation({
|
||||||
mutationFn: (data: Record<string, unknown>) => agentsApi.update(agent.id, data, companyId),
|
mutationFn: (data: Record<string, unknown>) => agentsApi.update(agent.id, data, companyId),
|
||||||
|
onMutate: () => {
|
||||||
|
setAwaitingRefreshAfterSave(true);
|
||||||
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) });
|
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) });
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.urlKey) });
|
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.urlKey) });
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.configRevisions(agent.id) });
|
queryClient.invalidateQueries({ queryKey: queryKeys.agents.configRevisions(agent.id) });
|
||||||
},
|
},
|
||||||
|
onError: () => {
|
||||||
|
setAwaitingRefreshAfterSave(false);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onSavingChange(updateAgent.isPending);
|
if (awaitingRefreshAfterSave && agent !== lastAgentRef.current) {
|
||||||
}, [onSavingChange, updateAgent.isPending]);
|
setAwaitingRefreshAfterSave(false);
|
||||||
|
}
|
||||||
|
lastAgentRef.current = agent;
|
||||||
|
}, [agent, awaitingRefreshAfterSave]);
|
||||||
|
|
||||||
|
const isConfigSaving = updateAgent.isPending || awaitingRefreshAfterSave;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onSavingChange(isConfigSaving);
|
||||||
|
}, [onSavingChange, isConfigSaving]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -1066,7 +1089,7 @@ function ConfigurationTab({
|
|||||||
mode="edit"
|
mode="edit"
|
||||||
agent={agent}
|
agent={agent}
|
||||||
onSave={(patch) => updateAgent.mutate(patch)}
|
onSave={(patch) => updateAgent.mutate(patch)}
|
||||||
isSaving={updateAgent.isPending}
|
isSaving={isConfigSaving}
|
||||||
adapterModels={adapterModels}
|
adapterModels={adapterModels}
|
||||||
onDirtyChange={onDirtyChange}
|
onDirtyChange={onDirtyChange}
|
||||||
onSaveActionChange={onSaveActionChange}
|
onSaveActionChange={onSaveActionChange}
|
||||||
@@ -1140,7 +1163,7 @@ function RunListItem({ run, isSelected, agentId }: { run: HeartbeatRun; isSelect
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{(metrics.totalTokens > 0 || metrics.cost > 0) && (
|
{(metrics.totalTokens > 0 || metrics.cost > 0) && (
|
||||||
<div className="flex items-center gap-2 pl-5.5 text-[11px] text-muted-foreground">
|
<div className="flex items-center gap-2 pl-5.5 text-[11px] text-muted-foreground tabular-nums">
|
||||||
{metrics.totalTokens > 0 && <span>{formatTokens(metrics.totalTokens)} tok</span>}
|
{metrics.totalTokens > 0 && <span>{formatTokens(metrics.totalTokens)} tok</span>}
|
||||||
{metrics.cost > 0 && <span>${metrics.cost.toFixed(3)}</span>}
|
{metrics.cost > 0 && <span>${metrics.cost.toFixed(3)}</span>}
|
||||||
</div>
|
</div>
|
||||||
@@ -1231,9 +1254,15 @@ function RunsTab({
|
|||||||
|
|
||||||
/* ---- Run Detail (expanded) ---- */
|
/* ---- Run Detail (expanded) ---- */
|
||||||
|
|
||||||
function RunDetail({ run, agentRouteId, adapterType }: { run: HeartbeatRun; agentRouteId: string; adapterType: string }) {
|
function RunDetail({ run: initialRun, agentRouteId, adapterType }: { run: HeartbeatRun; agentRouteId: string; adapterType: string }) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { data: hydratedRun } = useQuery({
|
||||||
|
queryKey: queryKeys.runDetail(initialRun.id),
|
||||||
|
queryFn: () => heartbeatsApi.get(initialRun.id),
|
||||||
|
enabled: Boolean(initialRun.id),
|
||||||
|
});
|
||||||
|
const run = hydratedRun ?? initialRun;
|
||||||
const metrics = runMetrics(run);
|
const metrics = runMetrics(run);
|
||||||
const [sessionOpen, setSessionOpen] = useState(false);
|
const [sessionOpen, setSessionOpen] = useState(false);
|
||||||
const [claudeLoginResult, setClaudeLoginResult] = useState<ClaudeLoginResult | null>(null);
|
const [claudeLoginResult, setClaudeLoginResult] = useState<ClaudeLoginResult | null>(null);
|
||||||
@@ -1510,7 +1539,7 @@ function RunDetail({ run, agentRouteId, adapterType }: { run: HeartbeatRun; agen
|
|||||||
|
|
||||||
{/* Right column: metrics */}
|
{/* Right column: metrics */}
|
||||||
{hasMetrics && (
|
{hasMetrics && (
|
||||||
<div className="border-t sm:border-t-0 sm:border-l border-border p-4 grid grid-cols-2 gap-x-4 sm:gap-x-8 gap-y-3 content-center">
|
<div className="border-t sm:border-t-0 sm:border-l border-border p-4 grid grid-cols-2 gap-x-4 sm:gap-x-8 gap-y-3 content-center tabular-nums">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs text-muted-foreground">Input</div>
|
<div className="text-xs text-muted-foreground">Input</div>
|
||||||
<div className="text-sm font-medium font-mono">{formatTokens(metrics.input)}</div>
|
<div className="text-sm font-medium font-mono">{formatTokens(metrics.input)}</div>
|
||||||
@@ -2109,7 +2138,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
|||||||
{isLive && (
|
{isLive && (
|
||||||
<span className="flex items-center gap-1 text-xs text-cyan-400">
|
<span className="flex items-center gap-1 text-xs text-cyan-400">
|
||||||
<span className="relative flex h-2 w-2">
|
<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="animate-pulse 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 className="relative inline-flex rounded-full h-2 w-2 bg-cyan-400" />
|
||||||
</span>
|
</span>
|
||||||
Live
|
Live
|
||||||
|
|||||||
@@ -398,7 +398,7 @@ function LiveRunIndicator({
|
|||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<span className="relative flex h-2 w-2">
|
<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="animate-pulse 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 className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[11px] font-medium text-blue-600 dark:text-blue-400">
|
<span className="text-[11px] font-medium text-blue-600 dark:text-blue-400">
|
||||||
|
|||||||
@@ -244,7 +244,7 @@ export function Companies() {
|
|||||||
{issueCount} {issueCount === 1 ? "issue" : "issues"}
|
{issueCount} {issueCount === 1 ? "issue" : "issues"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5 tabular-nums">
|
||||||
<DollarSign className="h-3.5 w-3.5" />
|
<DollarSign className="h-3.5 w-3.5" />
|
||||||
<span>
|
<span>
|
||||||
{formatCents(company.spentMonthlyCents)}
|
{formatCents(company.spentMonthlyCents)}
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ export function Costs() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-2xl font-bold">
|
<p className="text-2xl font-bold tabular-nums">
|
||||||
{formatCents(data.summary.spendCents)}{" "}
|
{formatCents(data.summary.spendCents)}{" "}
|
||||||
<span className="text-base font-normal text-muted-foreground">
|
<span className="text-base font-normal text-muted-foreground">
|
||||||
{data.summary.budgetCents > 0
|
{data.summary.budgetCents > 0
|
||||||
@@ -192,7 +192,7 @@ export function Costs() {
|
|||||||
<StatusBadge status="terminated" />
|
<StatusBadge status="terminated" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right shrink-0 ml-2">
|
<div className="text-right shrink-0 ml-2 tabular-nums">
|
||||||
<span className="font-medium block">{formatCents(row.costCents)}</span>
|
<span className="font-medium block">{formatCents(row.costCents)}</span>
|
||||||
<span className="text-xs text-muted-foreground block">
|
<span className="text-xs text-muted-foreground block">
|
||||||
in {formatTokens(row.inputTokens)} / out {formatTokens(row.outputTokens)} tok
|
in {formatTokens(row.inputTokens)} / out {formatTokens(row.outputTokens)} tok
|
||||||
@@ -229,7 +229,7 @@ export function Costs() {
|
|||||||
<span className="truncate">
|
<span className="truncate">
|
||||||
{row.projectName ?? row.projectId ?? "Unattributed"}
|
{row.projectName ?? row.projectId ?? "Unattributed"}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-medium">{formatCents(row.costCents)}</span>
|
<span className="font-medium tabular-nums">{formatCents(row.costCents)}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -255,7 +255,7 @@ export function Dashboard() {
|
|||||||
to="/approvals"
|
to="/approvals"
|
||||||
description={
|
description={
|
||||||
<span>
|
<span>
|
||||||
{data.staleTasks} stale tasks
|
Awaiting board review
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1061,7 +1061,7 @@ export function DesignGuide() {
|
|||||||
<div className="text-foreground">[12:00:17] INFO Reconnected successfully</div>
|
<div className="text-foreground">[12:00:17] INFO Reconnected successfully</div>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="relative flex h-1.5 w-1.5">
|
<span className="relative flex h-1.5 w-1.5">
|
||||||
<span className="absolute inline-flex h-full w-full rounded-full bg-cyan-400 animate-ping" />
|
<span className="absolute inline-flex h-full w-full rounded-full bg-cyan-400 animate-pulse" />
|
||||||
<span className="inline-flex h-full w-full rounded-full bg-cyan-400" />
|
<span className="inline-flex h-full w-full rounded-full bg-cyan-400" />
|
||||||
</span>
|
</span>
|
||||||
<span className="text-cyan-400">Live</span>
|
<span className="text-cyan-400">Live</span>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Link, useLocation, useNavigate } from "@/lib/router";
|
import { Link, useLocation, useNavigate } from "@/lib/router";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { approvalsApi } from "../api/approvals";
|
import { approvalsApi } from "../api/approvals";
|
||||||
@@ -11,6 +11,7 @@ import { heartbeatsApi } from "../api/heartbeats";
|
|||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
|
import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb";
|
||||||
import { StatusIcon } from "../components/StatusIcon";
|
import { StatusIcon } from "../components/StatusIcon";
|
||||||
import { PriorityIcon } from "../components/PriorityIcon";
|
import { PriorityIcon } from "../components/PriorityIcon";
|
||||||
import { EmptyState } from "../components/EmptyState";
|
import { EmptyState } from "../components/EmptyState";
|
||||||
@@ -31,7 +32,6 @@ import {
|
|||||||
import {
|
import {
|
||||||
Inbox as InboxIcon,
|
Inbox as InboxIcon,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Clock,
|
|
||||||
ArrowUpRight,
|
ArrowUpRight,
|
||||||
XCircle,
|
XCircle,
|
||||||
X,
|
X,
|
||||||
@@ -40,59 +40,29 @@ import {
|
|||||||
import { Identity } from "../components/Identity";
|
import { Identity } from "../components/Identity";
|
||||||
import { PageTabBar } from "../components/PageTabBar";
|
import { PageTabBar } from "../components/PageTabBar";
|
||||||
import type { HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared";
|
import type { HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared";
|
||||||
|
import {
|
||||||
|
ACTIONABLE_APPROVAL_STATUSES,
|
||||||
|
getLatestFailedRunsByAgent,
|
||||||
|
getRecentTouchedIssues,
|
||||||
|
type InboxTab,
|
||||||
|
saveLastInboxTab,
|
||||||
|
} from "../lib/inbox";
|
||||||
|
import { useDismissedInboxItems } from "../hooks/useInboxBadge";
|
||||||
|
|
||||||
const STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
||||||
const RECENT_ISSUES_LIMIT = 100;
|
|
||||||
const FAILED_RUN_STATUSES = new Set(["failed", "timed_out"]);
|
|
||||||
const ACTIONABLE_APPROVAL_STATUSES = new Set(["pending", "revision_requested"]);
|
|
||||||
|
|
||||||
type InboxTab = "new" | "all";
|
|
||||||
type InboxCategoryFilter =
|
type InboxCategoryFilter =
|
||||||
| "everything"
|
| "everything"
|
||||||
| "issues_i_touched"
|
| "issues_i_touched"
|
||||||
| "join_requests"
|
| "join_requests"
|
||||||
| "approvals"
|
| "approvals"
|
||||||
| "failed_runs"
|
| "failed_runs"
|
||||||
| "alerts"
|
| "alerts";
|
||||||
| "stale_work";
|
|
||||||
type InboxApprovalFilter = "all" | "actionable" | "resolved";
|
type InboxApprovalFilter = "all" | "actionable" | "resolved";
|
||||||
type SectionKey =
|
type SectionKey =
|
||||||
| "issues_i_touched"
|
| "issues_i_touched"
|
||||||
| "join_requests"
|
| "join_requests"
|
||||||
| "approvals"
|
| "approvals"
|
||||||
| "failed_runs"
|
| "failed_runs"
|
||||||
| "alerts"
|
| "alerts";
|
||||||
| "stale_work";
|
|
||||||
|
|
||||||
const DISMISSED_KEY = "paperclip:inbox:dismissed";
|
|
||||||
|
|
||||||
function loadDismissed(): Set<string> {
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(DISMISSED_KEY);
|
|
||||||
return raw ? new Set(JSON.parse(raw)) : new Set();
|
|
||||||
} catch {
|
|
||||||
return new Set();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveDismissed(ids: Set<string>) {
|
|
||||||
localStorage.setItem(DISMISSED_KEY, JSON.stringify([...ids]));
|
|
||||||
}
|
|
||||||
|
|
||||||
function useDismissedItems() {
|
|
||||||
const [dismissed, setDismissed] = useState<Set<string>>(loadDismissed);
|
|
||||||
|
|
||||||
const dismiss = useCallback((id: string) => {
|
|
||||||
setDismissed((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
next.add(id);
|
|
||||||
saveDismissed(next);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return { dismissed, dismiss };
|
|
||||||
}
|
|
||||||
|
|
||||||
const RUN_SOURCE_LABELS: Record<string, string> = {
|
const RUN_SOURCE_LABELS: Record<string, string> = {
|
||||||
timer: "Scheduled",
|
timer: "Scheduled",
|
||||||
@@ -101,32 +71,6 @@ const RUN_SOURCE_LABELS: Record<string, string> = {
|
|||||||
automation: "Automation",
|
automation: "Automation",
|
||||||
};
|
};
|
||||||
|
|
||||||
function getStaleIssues(issues: Issue[]): Issue[] {
|
|
||||||
const now = Date.now();
|
|
||||||
return issues
|
|
||||||
.filter(
|
|
||||||
(i) =>
|
|
||||||
["in_progress", "todo"].includes(i.status) &&
|
|
||||||
now - new Date(i.updatedAt).getTime() > STALE_THRESHOLD_MS,
|
|
||||||
)
|
|
||||||
.sort((a, b) => new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime());
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLatestFailedRunsByAgent(runs: HeartbeatRun[]): HeartbeatRun[] {
|
|
||||||
const sorted = [...runs].sort(
|
|
||||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
|
||||||
);
|
|
||||||
const latestByAgent = new Map<string, HeartbeatRun>();
|
|
||||||
|
|
||||||
for (const run of sorted) {
|
|
||||||
if (!latestByAgent.has(run.agentId)) {
|
|
||||||
latestByAgent.set(run.agentId, run);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(latestByAgent.values()).filter((run) => FAILED_RUN_STATUSES.has(run.status));
|
|
||||||
}
|
|
||||||
|
|
||||||
function firstNonEmptyLine(value: string | null | undefined): string | null {
|
function firstNonEmptyLine(value: string | null | undefined): string | null {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
const line = value.split("\n").map((chunk) => chunk.trim()).find(Boolean);
|
const line = value.split("\n").map((chunk) => chunk.trim()).find(Boolean);
|
||||||
@@ -137,23 +81,6 @@ function runFailureMessage(run: HeartbeatRun): string {
|
|||||||
return firstNonEmptyLine(run.error) ?? firstNonEmptyLine(run.stderrExcerpt) ?? "Run exited with an error.";
|
return firstNonEmptyLine(run.error) ?? firstNonEmptyLine(run.stderrExcerpt) ?? "Run exited with an error.";
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeTimestamp(value: string | Date | null | undefined): number {
|
|
||||||
if (!value) return 0;
|
|
||||||
const timestamp = new Date(value).getTime();
|
|
||||||
return Number.isFinite(timestamp) ? timestamp : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function issueLastActivityTimestamp(issue: Issue): number {
|
|
||||||
const lastExternalCommentAt = normalizeTimestamp(issue.lastExternalCommentAt);
|
|
||||||
if (lastExternalCommentAt > 0) return lastExternalCommentAt;
|
|
||||||
|
|
||||||
const updatedAt = normalizeTimestamp(issue.updatedAt);
|
|
||||||
const myLastTouchAt = normalizeTimestamp(issue.myLastTouchAt);
|
|
||||||
if (myLastTouchAt > 0 && updatedAt <= myLastTouchAt) return 0;
|
|
||||||
|
|
||||||
return updatedAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readIssueIdFromRun(run: HeartbeatRun): string | null {
|
function readIssueIdFromRun(run: HeartbeatRun): string | null {
|
||||||
const context = run.contextSnapshot;
|
const context = run.contextSnapshot;
|
||||||
if (!context) return null;
|
if (!context) return null;
|
||||||
@@ -171,11 +98,13 @@ function FailedRunCard({
|
|||||||
run,
|
run,
|
||||||
issueById,
|
issueById,
|
||||||
agentName: linkedAgentName,
|
agentName: linkedAgentName,
|
||||||
|
issueLinkState,
|
||||||
onDismiss,
|
onDismiss,
|
||||||
}: {
|
}: {
|
||||||
run: HeartbeatRun;
|
run: HeartbeatRun;
|
||||||
issueById: Map<string, Issue>;
|
issueById: Map<string, Issue>;
|
||||||
agentName: string | null;
|
agentName: string | null;
|
||||||
|
issueLinkState: unknown;
|
||||||
onDismiss: () => void;
|
onDismiss: () => void;
|
||||||
}) {
|
}) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -227,6 +156,7 @@ function FailedRunCard({
|
|||||||
{issue ? (
|
{issue ? (
|
||||||
<Link
|
<Link
|
||||||
to={`/issues/${issue.identifier ?? issue.id}`}
|
to={`/issues/${issue.identifier ?? issue.id}`}
|
||||||
|
state={issueLinkState}
|
||||||
className="block truncate text-sm font-medium transition-colors hover:text-foreground no-underline text-inherit"
|
className="block truncate text-sm font-medium transition-colors hover:text-foreground no-underline text-inherit"
|
||||||
>
|
>
|
||||||
<span className="font-mono text-muted-foreground mr-1.5">
|
<span className="font-mono text-muted-foreground mr-1.5">
|
||||||
@@ -311,10 +241,19 @@ export function Inbox() {
|
|||||||
const [actionError, setActionError] = useState<string | null>(null);
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
const [allCategoryFilter, setAllCategoryFilter] = useState<InboxCategoryFilter>("everything");
|
const [allCategoryFilter, setAllCategoryFilter] = useState<InboxCategoryFilter>("everything");
|
||||||
const [allApprovalFilter, setAllApprovalFilter] = useState<InboxApprovalFilter>("all");
|
const [allApprovalFilter, setAllApprovalFilter] = useState<InboxApprovalFilter>("all");
|
||||||
const { dismissed, dismiss } = useDismissedItems();
|
const { dismissed, dismiss } = useDismissedInboxItems();
|
||||||
|
|
||||||
const pathSegment = location.pathname.split("/").pop() ?? "new";
|
const pathSegment = location.pathname.split("/").pop() ?? "recent";
|
||||||
const tab: InboxTab = pathSegment === "all" ? "all" : "new";
|
const tab: InboxTab =
|
||||||
|
pathSegment === "all" || pathSegment === "unread" ? pathSegment : "recent";
|
||||||
|
const issueLinkState = useMemo(
|
||||||
|
() =>
|
||||||
|
createIssueDetailLocationState(
|
||||||
|
"Inbox",
|
||||||
|
`${location.pathname}${location.search}${location.hash}`,
|
||||||
|
),
|
||||||
|
[location.pathname, location.search, location.hash],
|
||||||
|
);
|
||||||
|
|
||||||
const { data: agents } = useQuery({
|
const { data: agents } = useQuery({
|
||||||
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
||||||
@@ -326,6 +265,10 @@ export function Inbox() {
|
|||||||
setBreadcrumbs([{ label: "Inbox" }]);
|
setBreadcrumbs([{ label: "Inbox" }]);
|
||||||
}, [setBreadcrumbs]);
|
}, [setBreadcrumbs]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
saveLastInboxTab(tab);
|
||||||
|
}, [tab]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: approvals,
|
data: approvals,
|
||||||
isLoading: isApprovalsLoading,
|
isLoading: isApprovalsLoading,
|
||||||
@@ -385,22 +328,10 @@ export function Inbox() {
|
|||||||
enabled: !!selectedCompanyId,
|
enabled: !!selectedCompanyId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const staleIssues = useMemo(
|
const touchedIssues = useMemo(() => getRecentTouchedIssues(touchedIssuesRaw), [touchedIssuesRaw]);
|
||||||
() => (issues ? getStaleIssues(issues) : []).filter((i) => !dismissed.has(`stale:${i.id}`)),
|
const unreadTouchedIssues = useMemo(
|
||||||
[issues, dismissed],
|
() => touchedIssues.filter((issue) => issue.isUnreadForMe),
|
||||||
);
|
[touchedIssues],
|
||||||
const sortByMostRecentActivity = useCallback(
|
|
||||||
(a: Issue, b: Issue) => {
|
|
||||||
const activityDiff = issueLastActivityTimestamp(b) - issueLastActivityTimestamp(a);
|
|
||||||
if (activityDiff !== 0) return activityDiff;
|
|
||||||
return normalizeTimestamp(b.updatedAt) - normalizeTimestamp(a.updatedAt);
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const touchedIssues = useMemo(
|
|
||||||
() => [...touchedIssuesRaw].sort(sortByMostRecentActivity).slice(0, RECENT_ISSUES_LIMIT),
|
|
||||||
[sortByMostRecentActivity, touchedIssuesRaw],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const agentById = useMemo(() => {
|
const agentById = useMemo(() => {
|
||||||
@@ -500,17 +431,20 @@ export function Inbox() {
|
|||||||
|
|
||||||
const [fadingOutIssues, setFadingOutIssues] = useState<Set<string>>(new Set());
|
const [fadingOutIssues, setFadingOutIssues] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const invalidateInboxIssueQueries = () => {
|
||||||
|
if (!selectedCompanyId) return;
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listTouchedByMe(selectedCompanyId) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listUnreadTouchedByMe(selectedCompanyId) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(selectedCompanyId) });
|
||||||
|
};
|
||||||
|
|
||||||
const markReadMutation = useMutation({
|
const markReadMutation = useMutation({
|
||||||
mutationFn: (id: string) => issuesApi.markRead(id),
|
mutationFn: (id: string) => issuesApi.markRead(id),
|
||||||
onMutate: (id) => {
|
onMutate: (id) => {
|
||||||
setFadingOutIssues((prev) => new Set(prev).add(id));
|
setFadingOutIssues((prev) => new Set(prev).add(id));
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
if (selectedCompanyId) {
|
invalidateInboxIssueQueries();
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listTouchedByMe(selectedCompanyId) });
|
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listUnreadTouchedByMe(selectedCompanyId) });
|
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(selectedCompanyId) });
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onSettled: (_data, _error, id) => {
|
onSettled: (_data, _error, id) => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -523,6 +457,31 @@ export function Inbox() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const markAllReadMutation = useMutation({
|
||||||
|
mutationFn: async (issueIds: string[]) => {
|
||||||
|
await Promise.all(issueIds.map((issueId) => issuesApi.markRead(issueId)));
|
||||||
|
},
|
||||||
|
onMutate: (issueIds) => {
|
||||||
|
setFadingOutIssues((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
for (const issueId of issueIds) next.add(issueId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidateInboxIssueQueries();
|
||||||
|
},
|
||||||
|
onSettled: (_data, _error, issueIds) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
setFadingOutIssues((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
for (const issueId of issueIds) next.delete(issueId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, 300);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (!selectedCompanyId) {
|
if (!selectedCompanyId) {
|
||||||
return <EmptyState icon={InboxIcon} message="Select a company to view inbox." />;
|
return <EmptyState icon={InboxIcon} message="Select a company to view inbox." />;
|
||||||
}
|
}
|
||||||
@@ -535,7 +494,6 @@ export function Inbox() {
|
|||||||
dashboard.costs.monthUtilizationPercent >= 80 &&
|
dashboard.costs.monthUtilizationPercent >= 80 &&
|
||||||
!dismissed.has("alert:budget");
|
!dismissed.has("alert:budget");
|
||||||
const hasAlerts = showAggregateAgentError || showBudgetAlert;
|
const hasAlerts = showAggregateAgentError || showBudgetAlert;
|
||||||
const hasStale = staleIssues.length > 0;
|
|
||||||
const hasJoinRequests = joinRequests.length > 0;
|
const hasJoinRequests = joinRequests.length > 0;
|
||||||
const hasTouchedIssues = touchedIssues.length > 0;
|
const hasTouchedIssues = touchedIssues.length > 0;
|
||||||
|
|
||||||
@@ -547,25 +505,26 @@ export function Inbox() {
|
|||||||
const showFailedRunsCategory =
|
const showFailedRunsCategory =
|
||||||
allCategoryFilter === "everything" || allCategoryFilter === "failed_runs";
|
allCategoryFilter === "everything" || allCategoryFilter === "failed_runs";
|
||||||
const showAlertsCategory = allCategoryFilter === "everything" || allCategoryFilter === "alerts";
|
const showAlertsCategory = allCategoryFilter === "everything" || allCategoryFilter === "alerts";
|
||||||
const showStaleCategory = allCategoryFilter === "everything" || allCategoryFilter === "stale_work";
|
|
||||||
|
|
||||||
const approvalsToRender = tab === "new" ? actionableApprovals : filteredAllApprovals;
|
const approvalsToRender = tab === "all" ? filteredAllApprovals : actionableApprovals;
|
||||||
const showTouchedSection = tab === "new" ? hasTouchedIssues : showTouchedCategory && hasTouchedIssues;
|
const showTouchedSection =
|
||||||
|
tab === "all"
|
||||||
|
? showTouchedCategory && hasTouchedIssues
|
||||||
|
: tab === "unread"
|
||||||
|
? unreadTouchedIssues.length > 0
|
||||||
|
: hasTouchedIssues;
|
||||||
const showJoinRequestsSection =
|
const showJoinRequestsSection =
|
||||||
tab === "new" ? hasJoinRequests : showJoinRequestsCategory && hasJoinRequests;
|
tab === "all" ? showJoinRequestsCategory && hasJoinRequests : tab === "unread" && hasJoinRequests;
|
||||||
const showApprovalsSection =
|
const showApprovalsSection = tab === "all"
|
||||||
tab === "new"
|
? showApprovalsCategory && filteredAllApprovals.length > 0
|
||||||
? actionableApprovals.length > 0
|
: actionableApprovals.length > 0;
|
||||||
: showApprovalsCategory && filteredAllApprovals.length > 0;
|
|
||||||
const showFailedRunsSection =
|
const showFailedRunsSection =
|
||||||
tab === "new" ? hasRunFailures : showFailedRunsCategory && hasRunFailures;
|
tab === "all" ? showFailedRunsCategory && hasRunFailures : tab === "unread" && hasRunFailures;
|
||||||
const showAlertsSection = tab === "new" ? hasAlerts : showAlertsCategory && hasAlerts;
|
const showAlertsSection = tab === "all" ? showAlertsCategory && hasAlerts : tab === "unread" && hasAlerts;
|
||||||
const showStaleSection = tab === "new" ? hasStale : showStaleCategory && hasStale;
|
|
||||||
|
|
||||||
const visibleSections = [
|
const visibleSections = [
|
||||||
showFailedRunsSection ? "failed_runs" : null,
|
showFailedRunsSection ? "failed_runs" : null,
|
||||||
showAlertsSection ? "alerts" : null,
|
showAlertsSection ? "alerts" : null,
|
||||||
showStaleSection ? "stale_work" : null,
|
|
||||||
showApprovalsSection ? "approvals" : null,
|
showApprovalsSection ? "approvals" : null,
|
||||||
showJoinRequestsSection ? "join_requests" : null,
|
showJoinRequestsSection ? "join_requests" : null,
|
||||||
showTouchedSection ? "issues_i_touched" : null,
|
showTouchedSection ? "issues_i_touched" : null,
|
||||||
@@ -580,24 +539,43 @@ export function Inbox() {
|
|||||||
!isRunsLoading;
|
!isRunsLoading;
|
||||||
|
|
||||||
const showSeparatorBefore = (key: SectionKey) => visibleSections.indexOf(key) > 0;
|
const showSeparatorBefore = (key: SectionKey) => visibleSections.indexOf(key) > 0;
|
||||||
|
const unreadIssueIds = unreadTouchedIssues
|
||||||
|
.filter((issue) => !fadingOutIssues.has(issue.id))
|
||||||
|
.map((issue) => issue.id);
|
||||||
|
const canMarkAllRead = unreadIssueIds.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||||
<Tabs value={tab} onValueChange={(value) => navigate(`/inbox/${value === "all" ? "all" : "new"}`)}>
|
<Tabs value={tab} onValueChange={(value) => navigate(`/inbox/${value}`)}>
|
||||||
<PageTabBar
|
<PageTabBar
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
value: "new",
|
value: "recent",
|
||||||
label: "New",
|
label: "Recent",
|
||||||
},
|
},
|
||||||
|
{ value: "unread", label: "Unread" },
|
||||||
{ value: "all", label: "All" },
|
{ value: "all", label: "All" },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
{tab === "all" && (
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
{canMarkAllRead && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8"
|
||||||
|
onClick={() => markAllReadMutation.mutate(unreadIssueIds)}
|
||||||
|
disabled={markAllReadMutation.isPending}
|
||||||
|
>
|
||||||
|
{markAllReadMutation.isPending ? "Marking…" : "Mark all as read"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === "all" && (
|
||||||
|
<>
|
||||||
<Select
|
<Select
|
||||||
value={allCategoryFilter}
|
value={allCategoryFilter}
|
||||||
onValueChange={(value) => setAllCategoryFilter(value as InboxCategoryFilter)}
|
onValueChange={(value) => setAllCategoryFilter(value as InboxCategoryFilter)}
|
||||||
@@ -612,7 +590,6 @@ export function Inbox() {
|
|||||||
<SelectItem value="approvals">Approvals</SelectItem>
|
<SelectItem value="approvals">Approvals</SelectItem>
|
||||||
<SelectItem value="failed_runs">Failed runs</SelectItem>
|
<SelectItem value="failed_runs">Failed runs</SelectItem>
|
||||||
<SelectItem value="alerts">Alerts</SelectItem>
|
<SelectItem value="alerts">Alerts</SelectItem>
|
||||||
<SelectItem value="stale_work">Stale work</SelectItem>
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
@@ -631,8 +608,9 @@ export function Inbox() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{approvalsError && <p className="text-sm text-destructive">{approvalsError.message}</p>}
|
{approvalsError && <p className="text-sm text-destructive">{approvalsError.message}</p>}
|
||||||
@@ -646,9 +624,11 @@ export function Inbox() {
|
|||||||
<EmptyState
|
<EmptyState
|
||||||
icon={InboxIcon}
|
icon={InboxIcon}
|
||||||
message={
|
message={
|
||||||
tab === "new"
|
tab === "unread"
|
||||||
? "No issues you're involved in yet."
|
? "No new inbox items."
|
||||||
: "No inbox items match these filters."
|
: tab === "recent"
|
||||||
|
? "No recent inbox items."
|
||||||
|
: "No inbox items match these filters."
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -658,7 +638,7 @@ export function Inbox() {
|
|||||||
{showSeparatorBefore("approvals") && <Separator />}
|
{showSeparatorBefore("approvals") && <Separator />}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
{tab === "new" ? "Approvals Needing Action" : "Approvals"}
|
{tab === "unread" ? "Approvals Needing Action" : "Approvals"}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="grid gap-3">
|
<div className="grid gap-3">
|
||||||
{approvalsToRender.map((approval) => (
|
{approvalsToRender.map((approval) => (
|
||||||
@@ -749,6 +729,7 @@ export function Inbox() {
|
|||||||
run={run}
|
run={run}
|
||||||
issueById={issueById}
|
issueById={issueById}
|
||||||
agentName={agentName(run.agentId)}
|
agentName={agentName(run.agentId)}
|
||||||
|
issueLinkState={issueLinkState}
|
||||||
onDismiss={() => dismiss(`run:${run.id}`)}
|
onDismiss={() => dismiss(`run:${run.id}`)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -815,165 +796,67 @@ export function Inbox() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showStaleSection && (
|
|
||||||
<>
|
|
||||||
{showSeparatorBefore("stale_work") && <Separator />}
|
|
||||||
<div>
|
|
||||||
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
|
||||||
Stale Work
|
|
||||||
</h3>
|
|
||||||
<div className="divide-y divide-border border border-border">
|
|
||||||
{staleIssues.map((issue) => (
|
|
||||||
<div
|
|
||||||
key={issue.id}
|
|
||||||
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 min-w-0 flex-1 cursor-pointer flex-col gap-1 no-underline text-inherit sm:flex-row sm:items-center sm:gap-3"
|
|
||||||
>
|
|
||||||
<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="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="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" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showTouchedSection && (
|
{showTouchedSection && (
|
||||||
<>
|
<>
|
||||||
{showSeparatorBefore("issues_i_touched") && <Separator />}
|
{showSeparatorBefore("issues_i_touched") && <Separator />}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
|
||||||
My Recent Issues
|
|
||||||
</h3>
|
|
||||||
<div className="divide-y divide-border border border-border">
|
<div className="divide-y divide-border border border-border">
|
||||||
{touchedIssues.map((issue) => {
|
{(tab === "unread" ? unreadTouchedIssues : touchedIssues).map((issue) => {
|
||||||
const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id);
|
const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id);
|
||||||
const isFading = fadingOutIssues.has(issue.id);
|
const isFading = fadingOutIssues.has(issue.id);
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={issue.id}
|
key={issue.id}
|
||||||
to={`/issues/${issue.identifier ?? issue.id}`}
|
to={`/issues/${issue.identifier ?? issue.id}`}
|
||||||
|
state={issueLinkState}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
{/* Status icon - left column on mobile, inline on desktop */}
|
<span className="inline-flex h-4 w-4 shrink-0 items-center justify-center self-center">
|
||||||
<span className="shrink-0 sm:hidden">
|
{(isUnread || isFading) ? (
|
||||||
<StatusIcon status={issue.status} />
|
<span
|
||||||
</span>
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
{/* Right column on mobile: title + metadata stacked */}
|
onClick={(e) => {
|
||||||
<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.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
markReadMutation.mutate(issue.id);
|
markReadMutation.mutate(issue.id);
|
||||||
}
|
}}
|
||||||
}}
|
onKeyDown={(e) => {
|
||||||
className="shrink-0 self-center cursor-pointer sm:hidden"
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
aria-label="Mark as read"
|
e.preventDefault();
|
||||||
>
|
e.stopPropagation();
|
||||||
<span
|
markReadMutation.mutate(issue.id);
|
||||||
className={`block h-2 w-2 rounded-full bg-blue-600 dark:bg-blue-400 transition-opacity duration-300 ${
|
}
|
||||||
isFading ? "opacity-0" : "opacity-100"
|
}}
|
||||||
}`}
|
className="inline-flex h-4 w-4 cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-blue-500/20"
|
||||||
/>
|
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="inline-flex h-4 w-4" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="inline-flex shrink-0 self-center"><PriorityIcon priority={issue.priority} /></span>
|
||||||
|
<span className="inline-flex shrink-0 self-center"><StatusIcon status={issue.status} /></span>
|
||||||
|
<span className="shrink-0 self-center text-xs font-mono text-muted-foreground">
|
||||||
|
{issue.identifier ?? issue.id.slice(0, 8)}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1 text-sm">
|
||||||
|
<span className="line-clamp-2 min-w-0 sm:line-clamp-1 sm:block sm:truncate">
|
||||||
|
{issue.title}
|
||||||
</span>
|
</span>
|
||||||
)}
|
</span>
|
||||||
|
<span className="hidden shrink-0 self-center text-xs text-muted-foreground sm:block">
|
||||||
|
{issue.lastExternalCommentAt
|
||||||
|
? `commented ${timeAgo(issue.lastExternalCommentAt)}`
|
||||||
|
: `updated ${timeAgo(issue.updatedAt)}`}
|
||||||
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
|
import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
|
||||||
import { useParams, Link, useNavigate } from "react-router-dom";
|
import { Link, useLocation, useNavigate, useParams } from "@/lib/router";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { issuesApi } from "../api/issues";
|
import { issuesApi } from "../api/issues";
|
||||||
import { activityApi } from "../api/activity";
|
import { activityApi } from "../api/activity";
|
||||||
@@ -11,6 +11,7 @@ import { useCompany } from "../context/CompanyContext";
|
|||||||
import { usePanel } from "../context/PanelContext";
|
import { usePanel } from "../context/PanelContext";
|
||||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
|
import { readIssueDetailBreadcrumb } from "../lib/issueDetailBreadcrumb";
|
||||||
import { useProjectOrder } from "../hooks/useProjectOrder";
|
import { useProjectOrder } from "../hooks/useProjectOrder";
|
||||||
import { relativeTime, cn, formatTokens } from "../lib/utils";
|
import { relativeTime, cn, formatTokens } from "../lib/utils";
|
||||||
import { InlineEditor } from "../components/InlineEditor";
|
import { InlineEditor } from "../components/InlineEditor";
|
||||||
@@ -150,6 +151,7 @@ export function IssueDetail() {
|
|||||||
const { setBreadcrumbs } = useBreadcrumbs();
|
const { setBreadcrumbs } = useBreadcrumbs();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
const [moreOpen, setMoreOpen] = useState(false);
|
const [moreOpen, setMoreOpen] = useState(false);
|
||||||
const [mobilePropsOpen, setMobilePropsOpen] = useState(false);
|
const [mobilePropsOpen, setMobilePropsOpen] = useState(false);
|
||||||
const [detailTab, setDetailTab] = useState("comments");
|
const [detailTab, setDetailTab] = useState("comments");
|
||||||
@@ -213,6 +215,10 @@ export function IssueDetail() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const hasLiveRuns = (liveRuns ?? []).length > 0 || !!activeRun;
|
const hasLiveRuns = (liveRuns ?? []).length > 0 || !!activeRun;
|
||||||
|
const sourceBreadcrumb = useMemo(
|
||||||
|
() => readIssueDetailBreadcrumb(location.state) ?? { label: "Issues", href: "/issues" },
|
||||||
|
[location.state],
|
||||||
|
);
|
||||||
|
|
||||||
// Filter out runs already shown by the live widget to avoid duplication
|
// Filter out runs already shown by the live widget to avoid duplication
|
||||||
const timelineRuns = useMemo(() => {
|
const timelineRuns = useMemo(() => {
|
||||||
@@ -468,17 +474,17 @@ export function IssueDetail() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const titleLabel = issue?.title ?? issueId ?? "Issue";
|
const titleLabel = issue?.title ?? issueId ?? "Issue";
|
||||||
setBreadcrumbs([
|
setBreadcrumbs([
|
||||||
{ label: "Issues", href: "/issues" },
|
sourceBreadcrumb,
|
||||||
{ label: hasLiveRuns ? `🔵 ${titleLabel}` : titleLabel },
|
{ label: hasLiveRuns ? `🔵 ${titleLabel}` : titleLabel },
|
||||||
]);
|
]);
|
||||||
}, [setBreadcrumbs, issue, issueId, hasLiveRuns]);
|
}, [setBreadcrumbs, sourceBreadcrumb, issue, issueId, hasLiveRuns]);
|
||||||
|
|
||||||
// Redirect to identifier-based URL if navigated via UUID
|
// Redirect to identifier-based URL if navigated via UUID
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (issue?.identifier && issueId !== issue.identifier) {
|
if (issue?.identifier && issueId !== issue.identifier) {
|
||||||
navigate(`/issues/${issue.identifier}`, { replace: true });
|
navigate(`/issues/${issue.identifier}`, { replace: true, state: location.state });
|
||||||
}
|
}
|
||||||
}, [issue, issueId, navigate]);
|
}, [issue, issueId, navigate, location.state]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!issue?.id) return;
|
if (!issue?.id) return;
|
||||||
@@ -524,6 +530,7 @@ export function IssueDetail() {
|
|||||||
{i > 0 && <ChevronRight className="h-3 w-3 shrink-0" />}
|
{i > 0 && <ChevronRight className="h-3 w-3 shrink-0" />}
|
||||||
<Link
|
<Link
|
||||||
to={`/issues/${ancestor.identifier ?? ancestor.id}`}
|
to={`/issues/${ancestor.identifier ?? ancestor.id}`}
|
||||||
|
state={location.state}
|
||||||
className="hover:text-foreground transition-colors truncate max-w-[200px]"
|
className="hover:text-foreground transition-colors truncate max-w-[200px]"
|
||||||
title={ancestor.title}
|
title={ancestor.title}
|
||||||
>
|
>
|
||||||
@@ -558,7 +565,7 @@ export function IssueDetail() {
|
|||||||
{hasLiveRuns && (
|
{hasLiveRuns && (
|
||||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-cyan-500/10 border border-cyan-500/30 px-2 py-0.5 text-[10px] font-medium text-cyan-600 dark:text-cyan-400 shrink-0">
|
<span className="inline-flex items-center gap-1.5 rounded-full bg-cyan-500/10 border border-cyan-500/30 px-2 py-0.5 text-[10px] font-medium text-cyan-600 dark:text-cyan-400 shrink-0">
|
||||||
<span className="relative flex h-1.5 w-1.5">
|
<span className="relative flex h-1.5 w-1.5">
|
||||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-cyan-400 opacity-75" />
|
<span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-cyan-400 opacity-75" />
|
||||||
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-cyan-400" />
|
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-cyan-400" />
|
||||||
</span>
|
</span>
|
||||||
Live
|
Live
|
||||||
@@ -661,7 +668,7 @@ export function IssueDetail() {
|
|||||||
value={issue.description ?? ""}
|
value={issue.description ?? ""}
|
||||||
onSave={(description) => updateIssue.mutate({ description })}
|
onSave={(description) => updateIssue.mutate({ description })}
|
||||||
as="p"
|
as="p"
|
||||||
className="text-sm text-muted-foreground"
|
className="text-[15px] leading-7 text-foreground"
|
||||||
placeholder="Add a description..."
|
placeholder="Add a description..."
|
||||||
multiline
|
multiline
|
||||||
mentions={mentionOptions}
|
mentions={mentionOptions}
|
||||||
@@ -800,6 +807,7 @@ export function IssueDetail() {
|
|||||||
<Link
|
<Link
|
||||||
key={child.id}
|
key={child.id}
|
||||||
to={`/issues/${child.identifier ?? child.id}`}
|
to={`/issues/${child.identifier ?? child.id}`}
|
||||||
|
state={location.state}
|
||||||
className="flex items-center justify-between px-3 py-2 text-sm hover:bg-accent/20 transition-colors"
|
className="flex items-center justify-between px-3 py-2 text-sm hover:bg-accent/20 transition-colors"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
@@ -893,7 +901,7 @@ export function IssueDetail() {
|
|||||||
{!issueCostSummary.hasCost && !issueCostSummary.hasTokens ? (
|
{!issueCostSummary.hasCost && !issueCostSummary.hasTokens ? (
|
||||||
<div className="text-xs text-muted-foreground">No cost data yet.</div>
|
<div className="text-xs text-muted-foreground">No cost data yet.</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground">
|
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground tabular-nums">
|
||||||
{issueCostSummary.hasCost && (
|
{issueCostSummary.hasCost && (
|
||||||
<span className="font-medium text-foreground">
|
<span className="font-medium text-foreground">
|
||||||
${issueCostSummary.cost.toFixed(4)}
|
${issueCostSummary.cost.toFixed(4)}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useCallback, useRef } from "react";
|
import { useEffect, useMemo, useCallback, useRef } from "react";
|
||||||
import { useSearchParams } from "@/lib/router";
|
import { useLocation, useSearchParams } from "@/lib/router";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { issuesApi } from "../api/issues";
|
import { issuesApi } from "../api/issues";
|
||||||
import { agentsApi } from "../api/agents";
|
import { agentsApi } from "../api/agents";
|
||||||
@@ -7,6 +7,7 @@ import { heartbeatsApi } from "../api/heartbeats";
|
|||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
|
import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb";
|
||||||
import { EmptyState } from "../components/EmptyState";
|
import { EmptyState } from "../components/EmptyState";
|
||||||
import { IssuesList } from "../components/IssuesList";
|
import { IssuesList } from "../components/IssuesList";
|
||||||
import { CircleDot } from "lucide-react";
|
import { CircleDot } from "lucide-react";
|
||||||
@@ -14,6 +15,7 @@ import { CircleDot } from "lucide-react";
|
|||||||
export function Issues() {
|
export function Issues() {
|
||||||
const { selectedCompanyId } = useCompany();
|
const { selectedCompanyId } = useCompany();
|
||||||
const { setBreadcrumbs } = useBreadcrumbs();
|
const { setBreadcrumbs } = useBreadcrumbs();
|
||||||
|
const location = useLocation();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
@@ -63,6 +65,15 @@ export function Issues() {
|
|||||||
return ids;
|
return ids;
|
||||||
}, [liveRuns]);
|
}, [liveRuns]);
|
||||||
|
|
||||||
|
const issueLinkState = useMemo(
|
||||||
|
() =>
|
||||||
|
createIssueDetailLocationState(
|
||||||
|
"Issues",
|
||||||
|
`${location.pathname}${location.search}${location.hash}`,
|
||||||
|
),
|
||||||
|
[location.pathname, location.search, location.hash],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setBreadcrumbs([{ label: "Issues" }]);
|
setBreadcrumbs([{ label: "Issues" }]);
|
||||||
}, [setBreadcrumbs]);
|
}, [setBreadcrumbs]);
|
||||||
@@ -93,6 +104,7 @@ export function Issues() {
|
|||||||
agents={agents}
|
agents={agents}
|
||||||
liveIssueIds={liveIssueIds}
|
liveIssueIds={liveIssueIds}
|
||||||
viewStateKey="paperclip:issues-view"
|
viewStateKey="paperclip:issues-view"
|
||||||
|
issueLinkState={issueLinkState}
|
||||||
initialAssignees={searchParams.get("assignee") ? [searchParams.get("assignee")!] : undefined}
|
initialAssignees={searchParams.get("assignee") ? [searchParams.get("assignee")!] : undefined}
|
||||||
initialSearch={initialSearch}
|
initialSearch={initialSearch}
|
||||||
onSearchChange={handleSearchChange}
|
onSearchChange={handleSearchChange}
|
||||||
|
|||||||
@@ -2,6 +2,6 @@ import { defineConfig } from "vitest/config";
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
environment: "jsdom",
|
environment: "node",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user