6 Commits

Author SHA1 Message Date
xingyue 4a681930b1 feat: add retrospect-workflow for self-improvement
CI / check (push) Failing after 48s
2026-05-30 15:51:43 +08:00
xiaoju 852b86dded chore: sync solve-issue workflow from upstream
CI / check (push) Failing after 34s
Committer role: post-condition verification + Gitea API instead of tea

小橘 🍊
2026-05-28 23:52:02 +00:00
xiaonuo 58e7335923 Merge pull request 'feat: add search filtering with text highlighting to dashboard' (#8) from fix/7-search-filtering-with-highlight into main
CI / check (push) Failing after 38s
2026-05-28 22:57:09 +00:00
xiaoju 15daea8cc1 fix: resolve biome linter violations in search feature
CI / check (pull_request) Failing after 37s
Fix all linter violations identified by reviewer:

1. **noArrayIndexKey violation**: Changed React keys from array index to
   stable composite keys combining index and content (`${i}-${part}`)
   - Prevents potential performance and state issues when items reorder
   - Complies with React best practices for key stability

2. **Formatting fixes** (auto-applied by biome):
   - Converted single quotes to double quotes in regex strings
   - Added trailing comma in map function for consistency
   - Split long expect().toMatch() calls across multiple lines in tests
   - Improved code readability and consistency

All checks now pass:
-  bunx biome check (no violations)
-  bun test (all 17 tests pass)
-  bun run build (successful)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-28 16:47:12 +00:00
xiaoju f31c45db07 feat: add search filtering with text highlighting to dashboard
Implement client-side search functionality for the records list with real-time
text highlighting. Users can now filter records by searching across command,
device name, and record ID fields.

Features:
- Search input positioned above device filter buttons
- Case-insensitive substring matching across command, device, and ID fields
- Real-time highlighting of matched text with <mark> tags
- Highlight component for reusable text highlighting logic
- Optimized filtering with useMemo to prevent unnecessary recalculations
- Distinct empty states: "No records yet" vs "No matches found"
- Dark theme consistent styling with focus states
- 14 comprehensive TDD tests ensuring feature correctness

Technical implementation:
- Highlight component uses regex for case-insensitive matching
- Escapes special regex characters for safe query handling
- Filtered results computed via useMemo with [records, searchQuery] dependencies
- Integrates seamlessly with existing device filtering
- Yellow highlight (#fbbf24) with dark text for visibility on dark theme

Resolves issue #7

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-28 16:44:02 +00:00
xiaonuo 217e380ff4 Merge pull request 'feat: Rebrand from UWF Dashboard to Uncaged Dashboard' (#6) from fix/5-rebrand-uncaged-dashboard into main
CI / check (push) Failing after 1m6s
2026-05-28 16:13:12 +00:00
5 changed files with 501 additions and 10 deletions
+220
View File
@@ -0,0 +1,220 @@
name: "retrospect-workflow"
description: "Post-execution retrospective: analyze a completed thread, find inefficiencies, and improve the workflow definition."
roles:
analyst:
description: "Scans thread execution for anomalies and produces a findings report"
goal: "You are a workflow execution analyst. You review completed thread data to find inefficiencies, wasted effort, and procedure gaps."
capabilities:
- data-analysis
procedure: |
You receive a completed thread ID in your task prompt.
Phase 0 — Validation (must pass before any analysis):
1. Run `uwf step list <thread-id>` to get thread metadata including the workflow hash
2. Run `uwf workflow show <workflow-hash>` to get the workflow name
3. Verify the workflow exists locally: check `.workflows/<name>.yaml` in the current repo
- If NOT found: output $status=wrong_project with the workflow name. Do NOT proceed.
4. Compare the thread's workflow hash against the current registered version:
- Run `uwf workflow show <name>` to get the current hash
- If hashes differ: the thread ran on an older version. Note this — you will need to diff versions after analysis.
Phase 1 — Overview scan:
5. From the step list, compute a health signal for each step:
- Duration: flag if >2x the median of other steps
- Output tokens: flag if >2x the median
- Status flow: flag non-happy-path transitions (rejected, fix_code, fix_spec, hook_failed)
- Step count: flag if the same role appears more than expected (indicates loops)
6. If no anomalies found AND versions match: output $status=clean
7. If no anomalies found BUT versions differ:
- Diff the two workflow versions to check if any procedure changes are relevant
- If the current version already addresses potential concerns: output $status=clean with a note
- Otherwise: proceed to Phase 2
Phase 2 — Targeted deep-dive (only for flagged steps):
8. For each flagged step, run `uwf step show <hash>` to get the detail with turns
9. Analyze the turn sequence for:
- Repeated tool calls with the same or similar input (blind retries)
- Tool errors followed by no strategy change (same approach retried)
- Unnecessary exploration (reading files or running commands unrelated to the task)
- Hallucinated commands or flags (commands that don't exist or wrong syntax)
- Excessive turns before reaching the goal
10. For each finding, record:
- Which role and step hash
- What happened (specific turn indices and commands)
- Root cause hypothesis (procedure gap, missing pitfall, unclear instruction)
- Suggested fix (what to add/change in the procedure)
11. If versions differ: compare findings against the version diff.
Mark any finding that is already fixed in the current version as "resolved_in_current".
Only report findings that are NOT yet addressed.
Output a structured findings report. Set $status=clean if nothing actionable, $status=findings if unresolved issues exist, or $status=wrong_project if the workflow doesn't belong here.
output: "A findings report with per-issue root cause and suggested procedure fixes. Set $status to clean or findings (with report hash)."
frontmatter:
oneOf:
- properties:
$status: { const: "clean" }
summary: { type: string }
required: [$status, summary]
- properties:
$status: { const: "findings" }
report: { type: string }
targetWorkflow: { type: string }
required: [$status, report, targetWorkflow]
- properties:
$status: { const: "wrong_project" }
workflowName: { type: string }
required: [$status, workflowName]
proposer:
description: "Translates findings into concrete workflow edits"
goal: "You are a workflow improvement proposer. You read the analyst's findings and produce specific, minimal edits to the workflow YAML."
capabilities:
- planning
procedure: |
1. Read the analyst's findings report from your task prompt
2. Locate the target workflow YAML:
- Workflow definitions live in the WORKFLOW ENGINE repo (where `uwf` is developed), NOT in the repo that was analyzed.
- Find it via: `uwf workflow show <targetWorkflow> --format yaml` to read the current definition
- The physical file is `.workflows/<targetWorkflow>.yaml` in the workflow engine repo
- Use `git rev-parse --show-toplevel` in the current directory to find the workflow engine repo root
3. Read the current workflow YAML to understand existing procedures
4. For each finding, draft a minimal edit:
- Prefer adding a pitfall note or clarifying instruction over restructuring
- If a procedure step is ambiguous, make it explicit
- If a tool usage pattern is wrong, add a "Do NOT" or "IMPORTANT" note
- Keep edits surgical — don't rewrite procedures that work fine
5. Check if existing tests need updating (search for test files referencing the workflow)
6. Produce a change plan as CAS text node via `uwf cas put-text "<plan>"`
The plan should list each edit with:
- File path
- What to change (old text → new text, or addition)
- Why (linked to which finding)
- Any test updates needed
output: "A change plan stored in CAS. Set $status to ready (with plan hash and repoPath) or no_action (if findings don't warrant changes)."
frontmatter:
oneOf:
- properties:
$status: { const: "ready" }
plan: { type: string }
repoPath: { type: string }
required: [$status, plan, repoPath]
- properties:
$status: { const: "no_action" }
reason: { type: string }
required: [$status, reason]
developer:
description: "Applies the proposed workflow edits"
goal: "You are a developer agent. You apply workflow YAML edits and update related tests."
capabilities:
- coding
procedure: |
IMPORTANT: Always work in a git worktree, NEVER modify the main working directory directly.
The workflow definitions live in THIS repo (the workflow engine), not the repo that was analyzed.
Before starting any work, set up an isolated worktree:
1. Use `git rev-parse --show-toplevel` to find the repo root (do NOT use repoPath from proposer — that's the analyzed repo)
2. `git fetch origin` to get latest refs
3. `git worktree add .worktrees/retrospect/<short-slug> -b retrospect/<short-slug> origin/main`
4. `cd .worktrees/retrospect/<short-slug> && bun install`
5. ALL subsequent work must happen inside the worktree directory.
Then apply changes:
6. Read the change plan from CAS: `uwf cas get <plan hash>`
7. Apply each edit from the plan to the workflow YAML
8. Update or add tests as specified in the plan
9. Run `bun run build` and `bun test` to verify
10. Run `bun run check` for lint
11. Commit with message: `improve: <workflow-name> — <brief summary>`
output: "List all files changed and provide a summary. Set $status to done (with branch/worktree), or failed (with reason)."
frontmatter:
oneOf:
- properties:
$status: { const: "done" }
branch: { type: string }
worktree: { type: string }
required: [$status, branch, worktree]
- properties:
$status: { const: "failed" }
reason: { type: string }
required: [$status, reason]
reviewer:
description: "Reviews the workflow edits for correctness"
goal: "You are a reviewer. You verify that workflow edits are minimal, correct, and actually address the findings."
capabilities:
- code-review
procedure: |
The worktree path is provided in your task prompt. cd into it first.
Review criteria:
1. Each edit must trace back to a specific finding — no drive-by changes
2. Edits should be minimal — don't rewrite working procedures
3. New pitfall notes or instructions must be clear and actionable
4. Tests must be updated if assertions changed
5. `bun run build` and `bun test` must pass
6. `bunx biome check` must pass
IMPORTANT: `tea pr create` must run from the MAIN repo directory (not a worktree), because tea cannot detect the repo from worktree `.git` files.
output: "Explain your decision. Set $status to approved (with branch/worktree) or rejected (with comments)."
frontmatter:
oneOf:
- properties:
$status: { const: "approved" }
branch: { type: string }
worktree: { type: string }
required: [$status, branch, worktree]
- properties:
$status: { const: "rejected" }
comments: { type: string }
worktree: { type: string }
required: [$status, comments, worktree]
committer:
description: "Commits and creates PR"
goal: "You are a committer agent. You create a clean commit and push a PR."
capabilities: []
procedure: |
The worktree path, branch name, and repo info are provided in your task prompt.
cd into the worktree first.
Note: You inherit the developer's worktree and branch. Do NOT create a new branch.
1. Stage all changes: `git add -A`
2. Commit with a descriptive message: `git commit -m "improve: <workflow> — <summary>"`
3. Push the branch: `git push -u origin <branch-name>`
- If push hook fails: capture the error log in your output, mark hook_failed
4. On push success: create a PR via `tea pr create --title "..." --description "..."`
- IMPORTANT: `tea pr create` must run from the MAIN repo directory (not a worktree), because tea cannot detect the repo from worktree `.git` files. cd to the repo root first.
- Do NOT pass `--repo` — let tea auto-detect from the main repo's git remote.
- PR description must include: What / Why / Findings / Changes sections
- On tea failure: capture stderr/stdout, include PR details for manual creation, mark hook_failed
5. After PR creation, clean up the worktree:
- cd to the repo root (parent of .worktrees)
- `git worktree remove <worktree-path>`
output: "Include PR URL on success or error log on failure. Set $status to committed (with prUrl) or hook_failed (with error)."
frontmatter:
oneOf:
- properties:
$status: { const: "committed" }
prUrl: { type: string }
required: [$status, prUrl]
- properties:
$status: { const: "hook_failed" }
error: { type: string }
required: [$status, error]
graph:
$START:
_: { role: "analyst", prompt: "Analyze completed thread {{{threadId}}} for execution anomalies." }
analyst:
clean: { role: "$END", prompt: "No issues found. Thread executed cleanly." }
findings: { role: "proposer", prompt: "Findings report: {{{report}}}. Target workflow: {{{targetWorkflow}}}. Propose minimal edits." }
wrong_project: { role: "$END", prompt: "Thread uses workflow '{{{workflowName}}}' which does not exist in this project. Run retrospect from the correct repo." }
proposer:
no_action: { role: "$END", prompt: "No actionable changes needed: {{{reason}}}." }
ready: { role: "developer", prompt: "Apply the change plan (CAS hash: {{{plan}}}) to the workflow definitions in this repo." }
developer:
done: { role: "reviewer", prompt: "Review workflow edits on branch {{{branch}}} at {{{worktree}}}." }
failed: { role: "$END", prompt: "Developer failed: {{{reason}}}. Ending workflow." }
reviewer:
rejected: { role: "developer", prompt: "Reviewer rejected: {{{comments}}}. Fix the issues in {{{worktree}}}." }
approved: { role: "committer", prompt: "Approved. Commit and push branch {{{branch}}} from {{{worktree}}}." }
committer:
hook_failed: { role: "developer", prompt: "Push hook failed: {{{error}}}. Fix and re-submit." }
committed: { role: "$END", prompt: "PR created: {{{prUrl}}}. Workflow improved." }
+5 -6
View File
@@ -242,12 +242,11 @@ roles:
capabilities: []
procedure: "The worktree path, branch name, and repo remote (owner/repo) are provided in your task prompt.\ncd into the worktree first.\n\nNote: You inherit the developer's worktree and branch. Do NOT\
\ create a new branch.\n1. Stage all changes: `git add -A`\n2. Commit with a descriptive message referencing the issue: `git commit -m \"type: description\\n\\nFixes #N\"`\n3. Push the branch: `git\
\ push -u origin <branch-name>`\n - If push hook fails: capture the error log in your output, mark hook_failed\n4. On push success: create a PR via `tea pr create --repo <repoRemote> --title \"\
...\" --description \"...\"`\n - The repo remote (owner/repo format, e.g. \"uncaged/workflow\") is given in your task prompt — use it directly, do NOT try to parse it from git remote URL.\n -\
\ PR description must include: What / Why / Changes / Ref sections, with `Fixes #N` in Ref\n - If `tea pr create` fails, try the Gitea API as fallback:\n ```bash\n GITEA_TOKEN=$(cfg get\
\ GITEA_TOKEN)\n curl -s -X POST -H \"Authorization: token $GITEA_TOKEN\" -H \"Content-Type: application/json\" \\\n \"https://git.shazhou.work/api/v1/repos/<owner>/<repo>/pulls\" \\\n \
\ -d '{\"title\":\"...\",\"body\":\"...\",\"head\":\"<branch>\",\"base\":\"main\"}'\n ```\n - On total failure: capture stderr/stdout, include PR details for manual creation, mark hook_failed\n\
5. After PR creation, clean up the worktree:\n - cd to the repo root (parent of .worktrees)\n - `git worktree remove <worktree-path>`"
\ push -u origin <branch-name>`\n4. **Verify push succeeded** — run `git ls-remote origin <branch-name>` and confirm it prints a commit hash.\n - If no output or push failed: capture the error, mark hook_failed\n\
5. Create a PR using the Gitea API (do NOT use `tea pr create` — it fails in worktrees):\n ```bash\n GITEA_TOKEN=$(cfg get GITEA_TOKEN)\n curl -s -X POST -H \"Authorization: token $GITEA_TOKEN\" -H \"Content-Type: application/json\" \\\n\
\ \"https://git.shazhou.work/api/v1/repos/<owner>/<repo>/pulls\" \\\n -d '{\"title\":\"...\",\"body\":\"...\",\"head\":\"<branch>\",\"base\":\"main\"}'\n ```\n - The repo remote (owner/repo format, e.g. \"uncaged/workflow\") is given in your task prompt — use it directly.\n\
\ - PR body must include: What / Why / Changes / Ref sections, with `Fixes #N` in Ref\n6. **Verify PR was created** — parse the curl response JSON: it must contain a `\"number\"` field. Print the PR URL.\n\
\ - If curl returns an error or no number field: capture the response, mark hook_failed\n7. After PR creation, clean up the worktree:\n - cd to the repo root (parent of .worktrees)\n - `git worktree remove <worktree-path>`"
output: Include PR URL on success or error log on failure. Set $status to committed (with prUrl) or hook_failed (with error).
frontmatter:
oneOf:
+20
View File
@@ -57,6 +57,26 @@ h1 {
color: #666;
font-size: 0.85rem;
}
.search-input {
width: 100%;
background: #2a2a4a;
color: #e0e0e0;
border: 1px solid #3a3a5a;
padding: 10px 14px;
border-radius: 6px;
font-size: 0.9rem;
margin-bottom: 12px;
}
.search-input:focus {
outline: none;
border-color: #7c8aff;
}
mark {
background: #fbbf24;
color: #1a1a2e;
padding: 1px 2px;
border-radius: 2px;
}
.filters {
display: flex;
gap: 8px;
+205
View File
@@ -26,3 +26,208 @@ describe("HTML Page Title", () => {
expect(content).not.toContain("<title>UWF Dashboard</title>");
});
});
describe("Search Filtering with Highlight", () => {
it("should contain a search input element with class 'search-input'", () => {
const content = readFileSync(join(__dirname, "App.tsx"), "utf-8");
expect(content).toMatch(/<input[^>]*type=["'](text|search)["'][^>]*>/);
expect(content).toMatch(/className=["'][^"']*search-input[^"']*["']/);
expect(content).toMatch(/placeholder=["'][^"']*[Ss]earch[^"']*["']/);
});
it("should declare a search query state variable", () => {
const content = readFileSync(join(__dirname, "App.tsx"), "utf-8");
expect(content).toMatch(
/const\s+\[\s*\w+\s*,\s*set\w+\s*\]\s*=\s*useState[<\w>]*\(\s*["']["']?\s*\)/,
);
// Verify it's a string state for search
expect(content).toMatch(/useState<string>\(["''"]|useState\(["''"]/);
});
it("should use useMemo to compute filtered records", () => {
const content = readFileSync(join(__dirname, "App.tsx"), "utf-8");
expect(content).toContain("useMemo");
// Check for dependency array containing records and search variable
expect(content).toMatch(/useMemo\([\s\S]+?,\s*\[\s*records\s*,\s*\w+\s*\]\s*\)/);
});
it("should implement case-insensitive filtering on command, device, and id fields", () => {
const content = readFileSync(join(__dirname, "App.tsx"), "utf-8");
// Check for toLowerCase usage in filtering context
const lowerCaseCount = (content.match(/\.toLowerCase\(\)/g) || []).length;
expect(lowerCaseCount).toBeGreaterThanOrEqual(2); // At least for query and one field
// Check for includes method (used for substring matching)
expect(content).toMatch(/\.includes\(/);
// Verify filtering checks command, device, and id
expect(content).toMatch(/r\.command/);
expect(content).toMatch(/r\.device/);
expect(content).toMatch(/r\.id/);
});
it("should define a Highlight component that wraps matches with <mark> tags", () => {
const content = readFileSync(join(__dirname, "App.tsx"), "utf-8");
// Check for Highlight component definition
expect(content).toMatch(/function\s+Highlight|const\s+Highlight\s*[:=]/);
// Check for mark tag usage
expect(content).toContain("mark");
// Check for props handling (text and query or similar)
expect(content).toMatch(
/\{\s*text\s*,\s*query\s*\}|\{\s*text\s*:\s*\w+\s*,\s*query\s*:\s*\w+\s*\}/,
);
});
it("should apply Highlight component to command column in record rows", () => {
const content = readFileSync(join(__dirname, "App.tsx"), "utf-8");
// Check for Highlight usage with command field
expect(content).toMatch(/<Highlight[^>]*text=\{r\.command\}/);
// Verify it's within the command span context
const commandSpanMatch = content.match(
/<span[^>]*className=["']command["'][^>]*>[\s\S]*?<\/span>/,
);
expect(commandSpanMatch).toBeTruthy();
if (commandSpanMatch) {
expect(commandSpanMatch[0]).toContain("Highlight");
}
});
it("should apply Highlight component to device badge in record rows", () => {
const content = readFileSync(join(__dirname, "App.tsx"), "utf-8");
// Check for Highlight usage with device field
expect(content).toMatch(/<Highlight[^>]*text=\{r\.device\}/);
// Verify it's within the device-badge span context
const deviceBadgeMatch = content.match(
/<span[^>]*className=["']device-badge["'][^>]*>[\s\S]*?<\/span>/,
);
expect(deviceBadgeMatch).toBeTruthy();
if (deviceBadgeMatch) {
expect(deviceBadgeMatch[0]).toContain("Highlight");
}
});
it("should show 'No matches' when search has no results vs 'No records yet' when empty", () => {
const content = readFileSync(join(__dirname, "App.tsx"), "utf-8");
// Check for "No records yet" message
expect(content).toContain("No records yet");
// Check for "No matches" or similar search-specific empty message
expect(content).toMatch(/No matches|No search results|No records found/i);
});
it("should define .search-input styles consistent with dark theme", () => {
const cssContent = readFileSync(join(__dirname, "App.css"), "utf-8");
// Check for .search-input selector
expect(cssContent).toMatch(/\.search-input\s*\{/);
// Extract the search-input block
const searchInputBlock = cssContent.match(/\.search-input\s*\{[^}]+\}/);
expect(searchInputBlock).toBeTruthy();
if (searchInputBlock) {
const block = searchInputBlock[0];
// Dark background
expect(block).toMatch(/background:\s*#[0-9a-fA-F]{6}/);
// Light text
expect(block).toMatch(/color:\s*#[0-9a-fA-F]{6}/);
// Border
expect(block).toMatch(/border:/);
// Padding
expect(block).toMatch(/padding:/);
// Border radius
expect(block).toMatch(/border-radius:/);
}
});
it("should define mark element styles with yellow/orange background for dark theme visibility", () => {
const cssContent = readFileSync(join(__dirname, "App.css"), "utf-8");
// Check for mark selector
expect(cssContent).toMatch(/\bmark\s*\{|\.mark\s*\{/);
// Extract the mark block
const markBlock = cssContent.match(/\bmark\s*\{[^}]+\}/);
expect(markBlock).toBeTruthy();
if (markBlock) {
const block = markBlock[0];
// Yellow/orange background (hex codes starting with #f, #fb, #fc, #d, #e, etc.)
expect(block).toMatch(/background[^:]*:\s*#[def][0-9a-fA-F]{5}/);
// Should have color defined for text
expect(block).toMatch(/color:/);
}
});
it("should place search input above device filter buttons", () => {
const content = readFileSync(join(__dirname, "App.tsx"), "utf-8");
// Extract the return JSX section
const returnMatch = content.match(/return\s*\(([\s\S]+)\);?\s*\}/);
expect(returnMatch).toBeTruthy();
if (returnMatch) {
const jsx = returnMatch[1];
// Find position of search input
const searchInputPos = jsx.search(/<input[^>]*search-input/);
expect(searchInputPos).toBeGreaterThan(-1);
// Find position of filters div
const filtersPos = jsx.search(/<div[^>]*className=["']filters["']/);
expect(filtersPos).toBeGreaterThan(-1);
// Search input should come before filters
expect(searchInputPos).toBeLessThan(filtersPos);
}
});
it("should render filtered records instead of raw records in the list", () => {
const content = readFileSync(join(__dirname, "App.tsx"), "utf-8");
// Check for a filtered variable being mapped
const useMemoMatch = content.match(/const\s+(\w+)\s*=\s*useMemo/);
expect(useMemoMatch).toBeTruthy();
if (useMemoMatch) {
const filteredVar = useMemoMatch[1];
// Check that this variable is used in .map()
const mapPattern = new RegExp(`\\{${filteredVar}\\.map\\(`);
expect(content).toMatch(mapPattern);
}
// Additionally ensure raw records.map is NOT used in the render section
// (after the useMemo definition)
const useMemoIndex = content.indexOf("useMemo");
const recordsMapMatch = content.slice(useMemoIndex).match(/\{records\.map\(/);
expect(recordsMapMatch).toBeNull();
});
it("should handle empty search query gracefully", () => {
const content = readFileSync(join(__dirname, "App.tsx"), "utf-8");
// Check that filtering logic handles empty query
const useMemoBlock = content.match(/useMemo\([^}]+\}/);
expect(useMemoBlock).toBeTruthy();
// Should either have early return for empty query OR filter logic that handles it
expect(content).toMatch(/if\s*\(\s*!\w+|if\s*\(\s*\w+\.trim\(\)|\.includes\(/);
});
it("should import useMemo from React", () => {
const content = readFileSync(join(__dirname, "App.tsx"), "utf-8");
// Check for useMemo in React import (may have React, before the destructure)
expect(content).toMatch(/import\s+.*\{[^}]*useMemo[^}]*\}\s+from\s+["']react["']/);
});
});
+51 -4
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useState, useRef } from "react";
import React, { useEffect, useState, useRef, useMemo } from "react";
import { useNavigate } from "react-router-dom";
interface Record {
@@ -29,6 +29,25 @@ function fmtDuration(ms: number) {
return `${(ms / 1000).toFixed(1)}s`;
}
function Highlight({ text, query }: { text: string; query: string }) {
if (!query.trim()) return <>{text}</>;
const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`, "gi");
const parts = text.split(regex);
return (
<>
{parts.map((part, i) =>
regex.test(part) ? (
<mark key={`${i}-${part}`}>{part}</mark>
) : (
<span key={`${i}-${part}`}>{part}</span>
),
)}
</>
);
}
export default function App() {
const [records, setRecords] = useState<Record[]>([]);
const [workers, setWorkers] = useState<Worker[]>([]);
@@ -36,9 +55,23 @@ export default function App() {
[],
);
const [filter, setFilter] = useState("");
const [searchQuery, setSearchQuery] = useState<string>("");
const nav = useNavigate();
const wsRef = useRef<WebSocket | null>(null);
const filteredRecords = useMemo(() => {
if (!searchQuery.trim()) return records;
const lowerQuery = searchQuery.toLowerCase();
return records.filter((r) => {
return (
r.command.toLowerCase().includes(lowerQuery) ||
r.device.toLowerCase().includes(lowerQuery) ||
r.id.toLowerCase().includes(lowerQuery)
);
});
}, [records, searchQuery]);
useEffect(() => {
fetch(`/api/records${filter ? `?device=${filter}` : ""}`)
.then((r) => r.json())
@@ -94,6 +127,13 @@ export default function App() {
)}
</div>
</header>
<input
type="text"
className="search-input"
placeholder="Search records by command, device, or ID..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<div className="filters">
<button type="button" className={!filter ? "active" : ""} onClick={() => setFilter("")}>
All
@@ -111,15 +151,22 @@ export default function App() {
</div>
<div className="records">
{records.length === 0 && <div className="empty">No records yet</div>}
{records.map((r) => (
{records.length > 0 && filteredRecords.length === 0 && searchQuery && (
<div className="empty">No matches found</div>
)}
{filteredRecords.map((r) => (
<button
type="button"
key={r.id}
className="record-row"
onClick={() => nav(`/record/${r.id}`)}
>
<span className="device-badge">{r.device}</span>
<span className="command">{r.command}</span>
<span className="device-badge">
<Highlight text={r.device} query={searchQuery} />
</span>
<span className="command">
<Highlight text={r.command} query={searchQuery} />
</span>
<span className={`exit-code ${r.exitCode === 0 ? "success" : "error"}`}>
{r.exitCode === 0 ? "✓" : `${r.exitCode}`}
</span>