feat(tui): git view, project-scoped task stats, polished layout/theme
- Add a Git interactive view (hotkey [t]/[4]): branch + ahead/behind,
recent commits with detail strip, staged/unstaged/untracked files
panel, branches list, worktrees panel, and a [P] push modal with
pre-flight commit list and capture of stdout/stderr. ←→ cycles
status → branches → worktrees → commits → changes; ↑↓ navigates
rows in the focused list. 5s poll while mounted.
- Project-scoped task stats: BoardView pushes its selected project
path into the controller; refreshTUIStats now reads from a per-
project TaskStore via a shared getProjectStore helper. Project
switch triggers an immediate refresh via onBoardScopeChange.
- Unified MainHeader used by status and interactive modes — section
tabs ([1]–[5]) and interactive view tabs ([b]/[a]/[g]/[t]) always
visible; previously the interactive header replaced the main one.
- Logs panel keeps its size when expanding a single entry.
- 'f' cycles severity filter from any panel in status mode.
- BoardView: explicit cross-view shortcuts so g/a/t always switch
views regardless of input-handler ordering.
- BoardView column overlap fix: flexShrink={0} on structural rows.
- TaskCard always shows the title with wrap="wrap"; single short-id
pill, no duplicate id-as-title fallback.
- Stats panel reorg: StatRow helper, bold section headers, narrow-
width wrap for Heap and Memory trailing fragments.
- Lighter blue palette: cyanBright fg accents / cyan active bg /
logo gradient whiteBright → white → cyanBright → cyan → blue.
- System stats sampler: RSS, heap (V8 limit-aware color), external,
CPU%, load avg, system used/free. Heap thresholds scale off
--max-old-space-size automatically.
- Test fixture updated for the new InteractiveData.git block.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
.auth-token-recovery-overlay {
|
||||
z-index: 210;
|
||||
}
|
||||
|
||||
.auth-token-recovery-modal {
|
||||
width: min(560px, calc(100vw - (var(--space-xl) * 2)));
|
||||
}
|
||||
|
||||
.auth-token-recovery-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.auth-token-recovery-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-lg);
|
||||
padding: var(--space-lg) var(--space-xl);
|
||||
}
|
||||
|
||||
.auth-token-recovery-content p {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.auth-token-recovery-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.auth-token-recovery-field label {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.auth-token-recovery-actions {
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import "./AuthTokenRecoveryDialog.css";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { clearAuthToken, setAuthToken } from "../auth";
|
||||
|
||||
export interface AuthTokenRecoveryDialogProps {
|
||||
open: boolean;
|
||||
}
|
||||
|
||||
export function AuthTokenRecoveryDialog({ open }: AuthTokenRecoveryDialogProps) {
|
||||
const [tokenInput, setTokenInput] = useState("");
|
||||
const tokenInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
tokenInputRef.current?.focus();
|
||||
}, [open]);
|
||||
|
||||
const handleSetToken = useCallback(() => {
|
||||
const token = tokenInput.trim();
|
||||
if (!token) return;
|
||||
|
||||
setAuthToken(token);
|
||||
window.location.reload();
|
||||
}, [tokenInput]);
|
||||
|
||||
const handleClearAndRetry = useCallback(() => {
|
||||
clearAuthToken();
|
||||
window.location.reload();
|
||||
}, []);
|
||||
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="modal-overlay open auth-token-recovery-overlay"
|
||||
role="presentation"
|
||||
onKeyDownCapture={(event) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="modal auth-token-recovery-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="auth-token-recovery-title"
|
||||
aria-describedby="auth-token-recovery-description"
|
||||
>
|
||||
<div className="modal-header auth-token-recovery-header">
|
||||
<h2 id="auth-token-recovery-title">Authentication token required</h2>
|
||||
</div>
|
||||
|
||||
<div className="auth-token-recovery-content">
|
||||
<p id="auth-token-recovery-description">
|
||||
This dashboard session can't authenticate with the daemon. Set a replacement token or clear the
|
||||
current token and retry.
|
||||
</p>
|
||||
|
||||
<div className="auth-token-recovery-field">
|
||||
<label htmlFor="auth-token-recovery-input">Replacement token</label>
|
||||
<input
|
||||
ref={tokenInputRef}
|
||||
id="auth-token-recovery-input"
|
||||
className="input"
|
||||
type="password"
|
||||
value={tokenInput}
|
||||
onChange={(event) => setTokenInput(event.target.value)}
|
||||
placeholder="Paste token"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-actions auth-token-recovery-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={handleClearAndRetry}
|
||||
>
|
||||
Clear token and retry
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={handleSetToken}
|
||||
disabled={tokenInput.trim().length === 0}
|
||||
>
|
||||
Set token and reload
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { AuthTokenRecoveryDialog } from "../AuthTokenRecoveryDialog";
|
||||
import { clearAuthToken, setAuthToken } from "../../auth";
|
||||
|
||||
vi.mock("../../auth", () => ({
|
||||
setAuthToken: vi.fn(),
|
||||
clearAuthToken: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("AuthTokenRecoveryDialog", () => {
|
||||
const originalLocation = window.location;
|
||||
let reloadSpy: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
reloadSpy = vi.fn();
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
value: { ...originalLocation, reload: reloadSpy },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not render when closed", () => {
|
||||
render(<AuthTokenRecoveryDialog open={false} />);
|
||||
expect(screen.queryByRole("dialog", { name: "Authentication token required" })).toBeNull();
|
||||
});
|
||||
|
||||
it("renders a blocking dialog with disabled set button until token is entered", () => {
|
||||
render(<AuthTokenRecoveryDialog open={true} />);
|
||||
|
||||
expect(screen.getByRole("dialog", { name: "Authentication token required" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /close/i })).toBeNull();
|
||||
|
||||
const setTokenButton = screen.getByRole("button", { name: "Set token and reload" });
|
||||
expect(setTokenButton).toBeDisabled();
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Replacement token"), { target: { value: "abc123" } });
|
||||
expect(setTokenButton).toBeEnabled();
|
||||
});
|
||||
|
||||
it("trims and stores replacement token before reloading", () => {
|
||||
render(<AuthTokenRecoveryDialog open={true} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Replacement token"), { target: { value: " new-token " } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Set token and reload" }));
|
||||
|
||||
expect(setAuthToken).toHaveBeenCalledWith("new-token");
|
||||
expect(reloadSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("clears token and reloads when user retries without replacement token", () => {
|
||||
render(<AuthTokenRecoveryDialog open={true} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Clear token and retry" }));
|
||||
|
||||
expect(clearAuthToken).toHaveBeenCalledTimes(1);
|
||||
expect(reloadSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not dismiss on Escape key", () => {
|
||||
render(<AuthTokenRecoveryDialog open={true} />);
|
||||
|
||||
const overlay = document.querySelector(".auth-token-recovery-overlay");
|
||||
expect(overlay).toBeTruthy();
|
||||
|
||||
if (!overlay) {
|
||||
throw new Error("Expected auth token recovery overlay");
|
||||
}
|
||||
|
||||
fireEvent.keyDown(overlay, { key: "Escape" });
|
||||
|
||||
expect(screen.getByRole("dialog", { name: "Authentication token required" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user