Skip to content

Commit ddeea0c

Browse files
VikingLichensamwebexpertclaude
authored
feat(base64): add data URI image viewer and migrate to ts-common (#117)
* feat: improve loggers * feat(orchestrator): [data_uri_parsing_utils] add pure data URI parsing utilities Introduce src/utils/base64-image.utils.ts with three pure helpers: - parseDataUri(input): regex-extracts { mimeType, base64, ext } from a well-formed data URI, returns null for empty/plain/malformed input. Pattern requires the `data:` prefix, a `type/subtype` MIME, and a non-empty base64 payload after `;base64,`. - isImageMimeType(mimeType): delegates to VALID_IMAGE_TYPES from image.utils so the source of truth for valid image MIME stays in one place. - mimeToExt(mimeType): looks up a small MIME_TO_EXT_MAP for canonical overrides (image/jpeg → jpg, image/svg+xml → svg) and otherwise falls back to the MIME subtype with any `+suffix` stripped, which gives sensible extensions for unmapped types (application/json → json, audio/mp3 → mp3). Co-located base64-image.utils.test.ts uses parametrised it.each blocks with explicit Arrange / Act / Assert structure for all three functions, including the negative cases listed in the acceptance criteria (application/pdf and audio/mp3 for isImageMimeType, and empty / plain / malformed inputs for parseDataUri). Files changed: - src/utils/base64-image.utils.ts (new) - src/utils/base64-image.utils.test.ts (new) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(orchestrator): [route_and_tab_shell] register /base64/image route and Image URI tab Add a placeholder Base64 Image URI screen wired into the Base64 tab shell: - New `/base64/image` child route registered under the `/base64` parent, rendering a placeholder `Base64Image` screen with a "Coming soon" body. - New TAB_ITEMS entry `{ key: "/base64/image", label: "Image URI" }` in the Base64 tab container; the existing `/base64` → `/base64/string` index redirect is unchanged. Key decisions: - Export TAB_ITEMS from `src/screens/base64/base64.tsx` so the tab list can be asserted from a colocated test (mirrors the MENU_ITEMS pattern in app-side-menu.utils.tsx). - Placeholder screen reuses ScreenContainer + ScreenHeader so the route navigates cleanly today and leaves a natural slot for the upcoming Image URI feature work. Files changed: - src/routes/router.tsx (register base64ImageRoute) - src/screens/base64/base64.tsx (export TAB_ITEMS, add Image URI entry) - src/screens/base64/base64.test.tsx (new: TAB_ITEMS includes Image URI) - src/screens/base64/image/base64-image.tsx (new placeholder screen) - src/screens/base64/image/base64-image.test.tsx (new: exported component) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * chore: merge route_and_tab_shell and data_uri_parsing_utils branches - Register /base64/image route and Image URI tab entry - Add pure data URI parsing utilities and unit tests Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(orchestrator): [persisted_textarea_input] wire textarea to persisted Zustand store Add a Base64 Image URI screen input wired to a localStorage-persisted Zustand store, mirroring the base64-string store pattern. Key decisions: - New `useBase64ImageStore` exposes a single `inputText` field plus `setInputText`. Persisted under `etoolbox-base64-image` via `zustand/middleware`'s `persist` + `createJSONStorage(() => localStorage)`, so the value survives route navigation and full page refresh. - Initial value is empty (`""`) — there is no encode/decode flow yet for image data URIs, so a Chuck-Norris-style placeholder would be misleading. - `Base64Image` now renders a full-width antd `TextArea` bound to the store with the same monospace style and `autoSize` ranges used by `Base64String`, keeping the Base64 tabs visually consistent. Files changed: - src/screens/base64/image/base64-image.store.ts (new) - src/screens/base64/image/base64-image.store.test.ts (new) - src/screens/base64/image/base64-image.tsx (textarea + store wiring) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * chore: merge persisted_textarea_input branch Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(orchestrator): [live_image_preview] render live image from valid data URI Wire parseDataUri + isImageMimeType into Base64Image so a valid image data URI in the textarea renders an <img> live on every keystroke, constrained to the viewport. Key decisions: - New pure helper `getImagePreviewSrc(input)` in base64-image.utils.ts combines parseDataUri + isImageMimeType and returns the original data URI string when safe to render, otherwise null. Keeps the branching logic out of the component and fully unit-testable in a codebase with no DOM test environment. - The helper returns null for empty, plain, malformed, non-image (application/pdf, audio/mp3, text/plain), and image/svg+xml input. SVG is intentionally excluded because VALID_IMAGE_TYPES in image.utils.ts is the single source of truth for renderable image MIME types, and SVG can carry script payloads. - Component reads the helper synchronously on every render, so the preview updates immediately as the user types and disappears when the textarea is cleared — no button press required. - Preview styled with maxWidth: "100%" and maxHeight: "60vh" plus objectFit: "contain" so the decoded image always fits the viewport without distortion. Files changed: - src/utils/base64-image.utils.ts (add getImagePreviewSrc) - src/utils/base64-image.utils.test.ts (positive + negative cases) - src/screens/base64/image/base64-image.tsx (wire helper + <img>) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * chore: merge live_image_preview branch Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: improve logger infos * feat: detect agent call failure (like max tokens usage limit reached) * feat: usage of mime to detect extension * feat: usage of mime to detect extension * feat: mime and datauri scheme helpers * refactor: usage of @lichens-innovation/ts-common to apply DRY * chore: re-organize imports and usage of new 1.18 ts-common release * fix: remove ternary overusage * feat(orchestrator): [non_image_uri_link] render Open-in-new-tab link for non-image data URIs Wire a parsed-but-non-image data URI in the Base64 Image URI textarea into an antd Alert (type=info) containing an anchor that opens the full data URI in a new tab. Key decisions: - New pure helper getNonImageDataUri(input) in base64-image.utils.ts mirrors getImagePreviewSrc but inverts the MIME check: returns the input string when parseDataUri succeeds AND isImageMimeType is false, otherwise null. Keeps branching out of the component and unit-testable without a DOM environment. - The two helpers are mutually exclusive by construction, so the component can render the <img> preview and the Alert as independent siblings without an extra coordinator state — image data URIs only ever resolve the preview branch and non-image data URIs only ever resolve the alert branch. - Anchor uses target="_blank" plus rel="noopener noreferrer" to keep the new tab from gaining a window.opener reference back into the app. - SVG (image/svg+xml) is intentionally treated as a non-image URI here, matching getImagePreviewSrc — VALID_IMAGE_TYPES in image.utils.ts stays the single source of truth for renderable image MIME types. Files changed: - src/utils/base64-image.utils.ts (add getNonImageDataUri) - src/utils/base64-image.utils.test.ts (positive + negative cases) - src/screens/base64/image/base64-image.tsx (wire helper + Alert link) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(orchestrator): [metadata_card_and_download] add metadata card with download and clear toolbar Display a metadata Description list (Resolution, MIME, Extension, Size, Download link) below the live image preview, plus a toolbar with Clear and Download buttons. Key decisions: - New pure helpers in base64-image.utils.ts: * `getImageMetadata(input)` derives { mimeType, ext, base64, sizeBytes, sizeFormatted } from a data URI by composing parseDataUri + isImageMimeType + getBase64ApproxSize + pretty-bytes. Returns null for empty / malformed / non-image input, mirroring getImagePreviewSrc. * `getImageDownloadFilename(ext)` builds the canonical `image.{ext}` name in one place so the metadata link and toolbar button share the same filename. * `downloadImageDataUri({ dataUri, ext })` wraps downloadDataUrl so the toolbar download stays a one-liner in the component. - New `useImageDimensions(src)` hook returns `{ status, width, height }` and uses `new Image()` with onload / onerror to resolve natural dimensions. Cleanup nulls handlers so a stale image load cannot setState on an unmounted component. - `ResolutionValue` renders the dimensions on `loaded`, a dash on `error`, and a `<Spin size="small" />` for any other state (idle / loading) — since the metadata card is only mounted when there is a real image src, idle is effectively a transient "about to load" and showing the spinner avoids a flash of dash on first render. - Download link uses native `<a download="image.{ext}" href={dataUri}>` so it works without JS; the toolbar Download button calls downloadImageDataUri with the same filename so both paths produce identical output. - Clear button calls `setInputText("")`, which makes `getImagePreviewSrc` and `getImageMetadata` both return null and unmounts the preview image, the metadata card, and disables the toolbar download. Files changed: - src/utils/base64-image.utils.ts (add ImageMetadata, getImageMetadata, getImageDownloadFilename, downloadImageDataUri, loadImageDimensions) - src/utils/base64-image.utils.test.ts (cover new helpers) - src/screens/base64/image/use-image-dimensions.ts (new hook) - src/screens/base64/image/use-image-dimensions.test.ts (export smoke) - src/screens/base64/image/base64-image-metadata.tsx (Descriptions card) - src/screens/base64/image/base64-image-metadata.test.tsx (export smoke) - src/screens/base64/image/base64-image-toolbar.tsx (Clear / Download) - src/screens/base64/image/base64-image-toolbar.test.tsx (export smoke) - src/screens/base64/image/base64-image.tsx (wire metadata + toolbar) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat: ability to cap parallel implementers * feat: ability to cap parallel implementers * refactor: ai orchrestration now review only at the very end * chore: merge metadata_card_and_download and non_image_uri_link Merges two orchestrator branches into feat/data-uri-scheme-viewer: - metadata_card_and_download: adds Base64ImageMetadata card (resolution, MIME, size), Base64ImageToolbar with download/clear, and useImageDimensions hook; adapts utils to use ts-common for parseDataUri, isImageMimeType, mimeToExt - non_image_uri_link: adds getNonImageDataUri helper and renders an Alert with an open-in-new-tab link for non-image data URIs Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(base64-image): [review] fix Alert message prop and remove redundant Space wrapper - Alert: title → message so the "Open in new tab" link renders as visible content - Toolbar: remove the Space wrapper around the single Tooltip/Button Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: open non image data uri * refactor: generic renaming for data-uri schemes * chore: release 4.6.0 --------- Co-authored-by: André Masson <amwebexpert@gmail.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 099608a commit ddeea0c

156 files changed

Lines changed: 1927 additions & 1711 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.claude/hooks/block-force-push.gate.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
*/
44

55
import { execSync } from "node:child_process";
6+
67
import { deny, PreToolUseInput, readStdinJson } from "./hooks-common.ts";
78

89
const PROTECTED_BRANCHES = ["main", "master", "develop"];

ai-orchestrator/orchestrator.ts

Lines changed: 22 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
1+
import { isNotBlank } from "@lichens-innovation/ts-common";
12
import { logger } from "@lichens-innovation/ts-common/logger";
23
import { z } from "zod";
34

4-
import { isNotBlank } from "@lichens-innovation/ts-common";
55
import { setAgentLogDir } from "./utils/agent-logger.utils.ts";
66
import { loadPrompt, runAgent, runTypedAgent } from "./utils/agent.utils.ts";
77
import type { Issue, IssueWorktreeResult, OrchestratorOptions } from "./utils/orchestrator.types.ts";
88
import { Plan } from "./utils/plan.ts";
9-
import { deleteBranch, hasCommits, withWorktree } from "./utils/worktree.utils.ts";
9+
import { deleteBranch, getCurrentHead, hasCommits, withWorktree } from "./utils/worktree.utils.ts";
1010

1111
const plannerOutputSchema = z.object({
1212
issues: z.array(z.object({ id: z.string(), blockedBy: z.array(z.string()) })),
@@ -29,6 +29,9 @@ interface DeleteImplementationBranchesResult {
2929
failed: string[];
3030
}
3131

32+
// parallelization limit since this is getting costly to run in parallel
33+
const MAX_PARALLEL_UNBLOCKED_IMPLEMENTERS = 2;
34+
3235
export class Orchestrator {
3336
private readonly repoDir: string;
3437
private readonly maxIterations: number;
@@ -42,12 +45,14 @@ export class Orchestrator {
4245
}
4346

4447
async run(): Promise<void> {
48+
const initialHead = getCurrentHead({ repoDir: this.repoDir });
49+
4550
for (let iteration = 1; iteration <= this.maxIterations; iteration++) {
4651
logger.info(`\n=== Iteration ${iteration}/${this.maxIterations} ===\n`);
4752

4853
this.plan.load();
4954
await this.runPlanningPhase();
50-
const unblockedIssues = this.plan.getUnblocked();
55+
const unblockedIssues = this.plan.getUnblocked(MAX_PARALLEL_UNBLOCKED_IMPLEMENTERS);
5156

5257
if (unblockedIssues.length === 0) {
5358
this.logNoUnblockedIssuesStatus();
@@ -66,6 +71,7 @@ export class Orchestrator {
6671
}
6772

6873
if (this.plan.remainingAfkIssues.length === 0) {
74+
await this.runReviewPhase(initialHead);
6975
await this.cleanupImplementationBranches();
7076
}
7177

@@ -107,7 +113,7 @@ export class Orchestrator {
107113
options: {
108114
cwd: this.repoDir,
109115
model: "claude-opus-4-7",
110-
maxTurns: 5,
116+
maxTurns: 20,
111117
permissionMode: "bypassPermissions",
112118
},
113119
});
@@ -119,7 +125,7 @@ export class Orchestrator {
119125
logger.info("Planning complete.");
120126
}
121127

122-
private async implementAndReviewIssue(issue: Issue): Promise<IssueWorktreeResult> {
128+
private async implementIssue(issue: Issue): Promise<IssueWorktreeResult> {
123129
const branch = this.getBranchName(issue.id);
124130

125131
return withWorktree({
@@ -129,12 +135,9 @@ export class Orchestrator {
129135
await this.runImplementAgent({ issue, branch, worktreePath });
130136

131137
if (!hasCommits({ branch, repoDir: this.repoDir })) {
132-
logger.info(` ${issue.id}: no commits produced, skipping review`);
133138
return { issue, hasProducedCommits: false };
134139
}
135140

136-
await this.runReviewAgent({ issue, branch, worktreePath });
137-
138141
return { issue, hasProducedCommits: true };
139142
},
140143
});
@@ -162,26 +165,26 @@ export class Orchestrator {
162165
});
163166
}
164167

165-
private async runReviewAgent({ issue, branch, worktreePath }: IssueWorktreeParams): Promise<void> {
166-
logger.info(` ${issue.id}: reviewing code changes`);
168+
private async runReviewPhase(initialHead: string): Promise<void> {
169+
logger.info("Running review phase...");
167170

168171
await runAgent({
169172
prompt: loadPrompt({
170173
name: "review.md",
171-
substitutions: { BRANCH: branch, SOURCE_BRANCH: "HEAD" },
174+
substitutions: { BRANCH: "HEAD", SOURCE_BRANCH: initialHead },
172175
}),
173-
label: `review:${issue.id}`,
176+
label: "review",
174177
options: {
175-
cwd: worktreePath,
178+
cwd: this.repoDir,
176179
model: "claude-sonnet-4-6",
177-
maxTurns: 10,
180+
maxTurns: 100,
178181
permissionMode: "bypassPermissions",
179182
},
180183
});
181184
}
182185

183186
private async runImplementationPhase(unblockedIssues: Issue[]): Promise<Issue[]> {
184-
const settled = await Promise.allSettled(unblockedIssues.map((issue) => this.implementAndReviewIssue(issue)));
187+
const settled = await Promise.allSettled(unblockedIssues.map((issue) => this.implementIssue(issue)));
185188

186189
for (const [index, outcome] of settled.entries()) {
187190
if (outcome.status === "rejected") {
@@ -200,7 +203,7 @@ export class Orchestrator {
200203

201204
private async runMergePhase(completedIssues: Issue[]): Promise<void> {
202205
const completedBranches = completedIssues.map((issue) => this.getBranchName(issue.id));
203-
logger.info(`\n${completedBranches.length} branch(es) ready to merge.`);
206+
logger.info(`${completedBranches.length} branch(es) ready to merge.`);
204207

205208
const { merged, failed } = await runTypedAgent({
206209
prompt: loadPrompt({
@@ -215,7 +218,7 @@ export class Orchestrator {
215218
options: {
216219
cwd: this.repoDir,
217220
model: "claude-sonnet-4-6",
218-
maxTurns: 20,
221+
maxTurns: 100,
219222
permissionMode: "bypassPermissions",
220223
},
221224
});
@@ -225,9 +228,9 @@ export class Orchestrator {
225228
}
226229
this.plan.save();
227230

228-
logger.info(`Merged: ${merged.length === 0 ? "none" : merged.join(", ")}`);
231+
logger.info(`\tMerged: ${merged.length === 0 ? "none" : merged.join(", ")}`);
229232
if (failed.length > 0) {
230-
logger.info(`Failed to merge: ${failed.join(", ")}`);
233+
logger.info(`\tFailed to merge: ${failed.join(", ")}`);
231234
}
232235
}
233236

ai-orchestrator/plan.json

Lines changed: 78 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,103 @@
11
[
22
{
3-
"id": "scaffold_route",
4-
"title": "Scaffold /diff route, sidebar entry, and home card",
5-
"whatToBuild": "Add a /diff route to the router, a sidebar menu item, and a home screen card. The screen renders a placeholder (empty div or 'Coming soon' text). Navigation to /diff works end-to-end.",
3+
"id": "route_and_tab_shell",
4+
"title": "Register /base64/image route and Image URI tab entry",
5+
"whatToBuild": "Add a /base64/image child route to the router and a { key: '/base64/image', label: 'Image URI' } entry to TAB_ITEMS in the base64 tab container. The route renders a placeholder component. Navigating to /#/base64/image works end-to-end and the tab is visible in the tab bar.",
66
"acceptanceCriteria": [
7-
"Navigating to /#/diff renders the diff-viewer screen without errors",
8-
"Sidebar shows a Diff Viewer entry that links to /diff",
9-
"Home screen shows a Diff Viewer card that navigates to /diff"
7+
"Navigating to /#/base64/image renders without errors",
8+
"An 'Image URI' tab appears in the /base64 tab bar alongside String and File",
9+
"Clicking the tab navigates to /base64/image",
10+
"The /base64 index redirect to /base64/string is unchanged"
1011
],
1112
"blockedBy": [],
1213
"type": "AFK",
1314
"passes": true,
1415
"isPlanned": true
1516
},
1617
{
17-
"id": "core_diff_view",
18-
"title": "Two-panel text input with live side-by-side diff",
19-
"whatToBuild": "Install react-diff-viewer-continued. Create the Zustand store (original, modified text). Render two labeled textareas ('Original' / 'Modified') and a live side-by-side diff below using useDeferredValue for performance. Diff updates on every keystroke. Line numbers are shown in both panels.",
18+
"id": "data_uri_parsing_utils",
19+
"title": "Pure data URI parsing utilities and unit tests",
20+
"whatToBuild": "Implement base64-image.utils.ts with three pure functions: parseDataUri (regex-extracts mimeType, base64, ext from a raw string, returns null for non-matching input), isImageMimeType (checks against VALID_IMAGE_TYPES from image.utils.ts), and mimeToExt (maps MIME type to file extension, falls back to the MIME subtype segment). Co-locate base64-image.utils.test.ts with parametrised vitest cases for all three functions.",
2021
"acceptanceCriteria": [
21-
"Typing in either textarea updates the diff output live",
22-
"Diff renders side-by-side with 'Original' on the left and 'Modified' on the right",
23-
"Line numbers are visible in both panels",
24-
"Added lines are highlighted green, removed lines red"
22+
"parseDataUri returns { mimeType, base64, ext } for a well-formed image data URI",
23+
"parseDataUri returns null for empty string, plain text, and malformed data URIs",
24+
"isImageMimeType returns true for all VALID_IMAGE_TYPES entries and false for application/pdf and audio/mp3",
25+
"mimeToExt returns the correct extension for all mapped MIME types and a sensible fallback for unknown ones",
26+
"All test cases use it.each and follow the AAA pattern",
27+
"npx vitest run passes with no failures"
28+
],
29+
"blockedBy": [],
30+
"type": "AFK",
31+
"passes": true,
32+
"isPlanned": true
33+
},
34+
{
35+
"id": "persisted_textarea_input",
36+
"title": "Textarea wired to persisted Zustand store",
37+
"whatToBuild": "Implement base64-image.store.ts with a single inputText field persisted to localStorage via zustand/middleware, mirroring the base64-string store pattern. Render a full-width textarea in base64-image.tsx bound to the store. The input survives page navigation and browser refresh.",
38+
"acceptanceCriteria": [
39+
"Pasting text into the textarea updates inputText in the store",
40+
"Navigating away and back to /base64/image restores the previously pasted text",
41+
"Refreshing the page restores the previously pasted text",
42+
"The textarea is full-width with monospace font, consistent with other base64 tab inputs"
43+
],
44+
"blockedBy": [
45+
"route_and_tab_shell"
46+
],
47+
"type": "AFK",
48+
"passes": true,
49+
"isPlanned": true
50+
},
51+
{
52+
"id": "live_image_preview",
53+
"title": "Constrained image rendered live from a valid image data URI",
54+
"whatToBuild": "Wire parseDataUri and isImageMimeType into base64-image.tsx. When the input is a valid image data URI, render an <img> element with max-width: 100% and max-height: 60vh. The image updates immediately on every input change with no button press required. When the input is empty or not a valid image data URI, no image is shown.",
55+
"acceptanceCriteria": [
56+
"Pasting a valid image data URI renders the decoded image immediately",
57+
"The image is constrained to the viewport (max-width 100%, max-height 60vh)",
58+
"Clearing the textarea removes the image from the view",
59+
"Pasting non-image or malformed text shows no image and no error"
60+
],
61+
"blockedBy": [
62+
"data_uri_parsing_utils",
63+
"persisted_textarea_input"
64+
],
65+
"type": "AFK",
66+
"passes": true,
67+
"isPlanned": true
68+
},
69+
{
70+
"id": "metadata_card_and_download",
71+
"title": "Metadata card (resolution, MIME, size) with download and clear toolbar",
72+
"whatToBuild": "Below the image, display a metadata description list: Resolution (width x height via async HTMLImageElement load, with a spinner until resolved), MIME type, Extension, Size (getBase64ApproxSize + pretty-bytes), and a Download link (downloadDataUrl with filename image.{ext}). Add a toolbar with a Clear button (resets inputText to '') and a Download button. Show a dash fallback for resolution if the image fails to load.",
73+
"acceptanceCriteria": [
74+
"Resolution shows the correct pixel dimensions once the image loads",
75+
"A spinner is shown in place of resolution while dimensions are loading",
76+
"Resolution shows a dash if the image element fails to load",
77+
"MIME type, Extension, and Size display correct values for the pasted URI",
78+
"Clicking the Download link saves the file as image.{ext}",
79+
"Clicking Clear resets the input and removes the image and metadata",
80+
"The Download toolbar button triggers the same download as the metadata link"
2581
],
2682
"blockedBy": [
27-
"scaffold_route"
83+
"live_image_preview"
2884
],
2985
"type": "AFK",
3086
"passes": true,
3187
"isPlanned": true
3288
},
3389
{
34-
"id": "diff_controls",
35-
"title": "Ignore-whitespace toggle, swap button, and +/- summary",
36-
"whatToBuild": "Add an 'Ignore whitespace' checkbox (stored in Zustand) that suppresses whitespace-only diff lines. Add a Swap button that exchanges Original and Modified values. Add a summary bar showing '+X lines, −Y lines' computed from the diff result.",
90+
"id": "non_image_uri_link",
91+
"title": "Open-in-new-tab link for non-image data URIs",
92+
"whatToBuild": "When parseDataUri succeeds but isImageMimeType returns false, render an Ant Design Alert (type info) containing an anchor tag with href set to the full data URI and target='_blank'. No image, no metadata, and no download button are shown in this case.",
3793
"acceptanceCriteria": [
38-
"Checking 'Ignore whitespace' removes whitespace-only changes from the diff",
39-
"Clicking Swap exchanges the content of the two textareas",
40-
"Summary bar shows the correct count of added and removed lines",
41-
"Summary bar updates live as text changes"
94+
"Pasting a data:application/pdf;base64,... URI shows the info alert and 'Open in new tab' link",
95+
"Clicking the link opens the decoded content in a new browser tab",
96+
"No image preview or metadata is shown for non-image URIs",
97+
"Switching back to a valid image URI removes the alert and shows the image preview"
4298
],
4399
"blockedBy": [
44-
"core_diff_view"
100+
"live_image_preview"
45101
],
46102
"type": "AFK",
47103
"passes": true,

ai-orchestrator/utils/agent-logger.utils.ts

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,64 @@
11
// Per-agent log files: logs/implementer-{issueId}.log, logs/reviewer-{issueId}.log, logs/planner.log, logs/merger.log
22
import { isNullish } from "@lichens-innovation/ts-common";
3-
import { createLogger, type Logger } from "@lichens-innovation/ts-common/logger";
3+
import type { Logger } from "@lichens-innovation/ts-common/logger";
4+
import { mkdirSync } from "node:fs";
45
import path from "node:path";
56
import { fileURLToPath } from "node:url";
7+
import pino from "pino";
68

79
const __dirname = path.dirname(fileURLToPath(import.meta.url));
810

11+
const MESSAGE_KEY = "msg";
12+
13+
const basePinoOptions: pino.LoggerOptions = {
14+
timestamp: pino.stdTimeFunctions.isoTime,
15+
messageKey: MESSAGE_KEY,
16+
};
17+
18+
const createPrettyFileTransport = (logFile: string): pino.DestinationStream => {
19+
try {
20+
return pino.transport({
21+
target: "pino-pretty",
22+
options: {
23+
colorize: false,
24+
destination: logFile,
25+
append: true,
26+
mkdir: true,
27+
translateTime: "SYS:standard",
28+
messageKey: MESSAGE_KEY,
29+
},
30+
});
31+
} catch (error) {
32+
console.warn(`pino-pretty unavailable for "${logFile}", falling back to JSON`, error);
33+
mkdirSync(path.dirname(logFile), { recursive: true });
34+
return pino.destination({ dest: logFile, append: true, mkdir: true });
35+
}
36+
};
37+
38+
const buildLoggerApi = (pinoInstance: pino.Logger): Logger => {
39+
const logAtLevel =
40+
(level: "trace" | "debug" | "info" | "warn" | "error" | "fatal") =>
41+
(message: string, payload?: Record<string, unknown>): void => {
42+
if (isNullish(payload)) {
43+
pinoInstance[level](message);
44+
} else {
45+
pinoInstance[level](payload, message);
46+
}
47+
};
48+
49+
return {
50+
trace: logAtLevel("trace"),
51+
debug: logAtLevel("debug"),
52+
info: logAtLevel("info"),
53+
warn: logAtLevel("warn"),
54+
error: logAtLevel("error"),
55+
fatal: logAtLevel("fatal"),
56+
log: logAtLevel("info"),
57+
};
58+
};
59+
60+
const createFileLogger = (logFile: string): Logger => buildLoggerApi(pino(basePinoOptions, createPrettyFileTransport(logFile)));
61+
962
let logDir = path.join(__dirname, "..", "logs");
1063

1164
export const setAgentLogDir = (dir: string): void => {
@@ -40,7 +93,7 @@ export const getLoggerForLabel = (label: string): Logger => {
4093
return cached;
4194
}
4295

43-
const instance = createLogger({ logFile: file });
96+
const instance = createFileLogger(file);
4497
loggerCache.set(file, instance);
4598
return instance;
4699
};

0 commit comments

Comments
 (0)