Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
58678d9
docs(adr): 0002 frontend vertical-slice decomposition for apps/web go…
Jul 6, 2026
e09dc49
refactor(web): decompose MemorySection into a features/memory vertica…
Jul 6, 2026
0d8e4ed
fix(web): satisfy noUncheckedIndexedAccess in slice-boundary guard
Jul 6, 2026
1a881a1
fix(web): roll back master memory toggle on failed PATCH and enforce …
Jul 8, 2026
f2bdd1a
fix(web): roll back per-hook memory flag on thrown PATCH and enforce …
Jul 8, 2026
711f483
chore: re-trigger CI (flaky UI P0 entry-chrome-flows timeout, unrelat…
Jul 8, 2026
06aef19
fix(web): restore memory extractions after transport failures
Jul 13, 2026
10c0f13
fix(web): surface memory read failures
Jul 13, 2026
dc0da4a
fix(web): restore memory history when rollback reload fails
Jul 14, 2026
f99ea0a
fix(web): preserve memory state on auxiliary read failures
Jul 14, 2026
6bca511
test(web): cover connector load error fixture
Jul 14, 2026
7f15665
fix(web): reconcile overlapping memory deletes
Jul 14, 2026
d2e62d7
chore: drop stale apps/telemetry-worker lockfile importer after rebas…
Jul 14, 2026
06eadee
fix(web): resolve three async race conditions in the memory hooks
Jul 14, 2026
9c7ed8c
fix(web): harden memory hook staleness tracking against same-id and d…
Jul 14, 2026
9c84473
fix(web): close SSE handler-error masking and slice-boundary guard by…
Jul 14, 2026
ca8e269
test(web): close memory-slice coverage gaps to 100% branches/function…
Jul 14, 2026
e05c1cd
fix(web): fix the general invariant behind the last four review findings
Jul 14, 2026
8cbe142
fix(web): route memory connector discovery through the one existing t…
Jul 14, 2026
323ca9f
chore: re-trigger CI (flaky Playwright visual settings-workspace avat…
Jul 14, 2026
e8a5f83
fix(web): route reload()/reloadExtractions() through the same stalene…
Jul 14, 2026
554d8d9
fix(web): close five more guard bypasses codex found on an exhaustive…
Jul 14, 2026
28d38c1
fix(web): restore dropped extraction-hook fixes; land the config hydr…
Jul 14, 2026
e9f19fd
docs(adr): scope the slice-boundary guard as best-effort, not adversa…
Jul 14, 2026
81320e7
fix(web): extract a shared async-commit-guard, fix config/connector r…
Jul 14, 2026
4af2df6
fix(web): return preserved state (not []) from a rejected reloadExtra…
Jul 14, 2026
6e0e447
fix(web): invalidate the config hydration guard at write-settle too
Jul 15, 2026
88f9219
fix(web): reconcile extraction rows by content progression, not recep…
Jul 15, 2026
429ac90
refactor(web): rewrite useMemoryExtractions around one explicit order…
Jul 15, 2026
245a465
chore: re-trigger CI (flaky HomeView community-filter-decouple test, …
Jul 15, 2026
38d38e7
refactor(web): split the extraction-history store out of its hook
Jul 15, 2026
075b757
fix(web): fail fast on malformed 2xx entry reads; recognize JS-backed…
Jul 15, 2026
0381410
fix(web): split connector catalogue/status guards; fail fast on malfo…
Jul 15, 2026
1f62b41
refactor(web): rebuild slice-boundary guard's resolution on ts.resolv…
Jul 15, 2026
72f93f7
fix(web): route memory providers through shared required-field readers
Jul 15, 2026
f65eea0
fix(web): fix 3 nettee bugs, 2 self-found races, push memory-slice co…
Jul 15, 2026
d695f1e
fix(web): harden memory async state and response handling
Jul 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ Follow the root `AGENTS.md` first. This file only records module-level boundarie
- `apps/desktop`: Electron shell. Desktop does not guess the web port; it reads runtime status through sidecar IPC and opens the reported web URL.
- `apps/packaged`: Thin packaged Electron runtime entry. It starts packaged daemon/web sidecars, registers the `od://` entry protocol, and delegates desktop host behavior to `apps/desktop`.

## apps/web frontend refactors (vertical slices)

Before decomposing a large `apps/web` component (a "god-component" like `SettingsDialog`, `MemorySection`, or `ChatComposer`), read `docs/adr/0002-frontend-vertical-slice-decomposition.md` first. It is the canonical design for this work and covers: the layer split (wire DTOs → `packages/contracts`, transport adapters → `apps/web/src/providers/`, in-slice `features/<slice>/` owning ports + rules + hooks + dumb components + barrel), the injected-port hook paradigm, and the **Testing & coverage strategy for a slice** subsection (how to reach ≥95% honestly — classify each "unreachable" branch and apply the matching fix instead of `/* v8 ignore */`).

The `MemorySection` slice (`apps/web/src/features/memory/`) is the worked canary; mirror its structure. A detailed session working-log for that canary lives in the branch's `ADS-project-knowledge/reports/*-memory-slice-*handoff.md` when present.

## Daemon layout

- `apps/daemon/src/` contains only daemon app source.
Expand Down
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
"@types/node": "20.19.39",
"@types/react": "18.3.28",
"@types/react-dom": "18.3.7",
"@vitest/coverage-v8": "4.1.6",
"jsdom": "29.1.1",
"postcss": "8.5.15",
"tailwindcss": "4.3.0",
Expand Down
139 changes: 89 additions & 50 deletions apps/web/src/components/MemoryModelInline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,19 @@ import {
useEffect,
useId,
useMemo,
useRef,
useState,
} from 'react';
import { useT } from '../i18n';
import type {
MemoryExtractionConfig as MemoryExtractionConfigShape,
MemoryExtractionMaskedConfig,
MemoryExtractionProvider,
MemoryListResponse,
} from '@open-design/contracts';
import {
fetchMemoryExtractionConfig,
patchMemoryExtractionConfig,
} from '../providers/memory';
import type { AgentModelOption, ApiProtocol, ExecMode } from '../types';
import {
SUGGESTED_MODELS_BY_PROTOCOL,
Expand Down Expand Up @@ -160,30 +164,10 @@ function cliAgentLabel(agentId: string | null | undefined): string | null {
}

async function fetchMemoryExtraction(): Promise<MemoryExtractionMaskedConfig | null> {
try {
const resp = await fetch('/api/memory');
if (!resp.ok) return null;
const json = (await resp.json()) as MemoryListResponse;
return json.extraction ?? null;
} catch {
return null;
}
}

async function saveMemoryExtraction(
extraction: MemoryExtractionConfigShape | null,
): Promise<MemoryExtractionMaskedConfig | null | undefined> {
const resp = await fetch('/api/memory/config', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ extraction }),
});
if (!resp.ok) return undefined;
const json = (await resp.json()) as {
enabled: boolean;
extraction: MemoryExtractionMaskedConfig | null;
};
return json.extraction ?? null;
// A missing `extraction` field is a malformed successful response, not
// "no override". This field-specific reader deliberately preserves the
// memory list's legacy defaults for unrelated fields.
return fetchMemoryExtractionConfig();
}

export function MemoryModelInline({
Expand All @@ -204,21 +188,38 @@ export function MemoryModelInline({
const [customEditing, setCustomEditing] = useState(false);
const [customDraft, setCustomDraft] = useState('');
const [busy, setBusy] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
// Reads and writes target the same server-side extraction override. A late
// mount read must never overwrite a user save, and writes must reach the
// daemon in intent order (the debounce path can otherwise race a click).
const persistenceEpochRef = useRef(0);
const persistenceQueueRef = useRef<Promise<void>>(Promise.resolve());
const pendingPersistCountRef = useRef(0);
// Brief inline confirmation after Save / clear so the user knows
// their click did something even though the dropdown just settles.
const [flash, setFlash] = useState<string | null>(null);

useEffect(() => {
let cancelled = false;
void fetchMemoryExtraction().then((next) => {
if (cancelled) return;
setConfig(next);
if (next?.model) setCustomDraft(next.model);
});
const epoch = persistenceEpochRef.current;
void fetchMemoryExtraction().then(
(next) => {
// A save began after this read, so this snapshot is no longer allowed
// to rehydrate the picker over the user's newer intent.
if (cancelled || persistenceEpochRef.current !== epoch) return;
setConfig(next);
if (next?.model) setCustomDraft(next.model);
setSaveError(null);
},
() => {
if (cancelled || persistenceEpochRef.current !== epoch) return;
setSaveError(t('settings.autosaveError'));
},
);
return () => {
cancelled = true;
};
}, []);
}, [t]);

useEffect(() => {
if (!flash) return;
Expand Down Expand Up @@ -307,30 +308,56 @@ export function MemoryModelInline({
);

const persist = useCallback(
async (
(
next: MemoryExtractionConfigShape | null,
options?: { silent?: boolean },
) => {
): Promise<boolean> => {
// Claim the epoch synchronously, before React has a chance to render the
// busy state, so the initial GET cannot win the same-event race.
persistenceEpochRef.current += 1;
pendingPersistCountRef.current += 1;
setBusy(true);
try {
const result = await saveMemoryExtraction(next);
if (result !== undefined) {
const run = async (): Promise<boolean> => {
try {
const result = await patchMemoryExtractionConfig(next);
if (result === undefined) {
setSaveError(t('settings.autosaveError'));
return false;
}
setConfig(result);
// Skip the "Saved!" flash on background re-syncs (provider
// tab swap, base-URL keystroke autosave, key rotation). The
// user didn't click anything here; flashing every keystroke
// would feel like the picker is "fighting" them.
setSaveError(null);
// Skip the "Saved!" flash on background re-syncs (provider tab swap,
// base-URL keystroke autosave, key rotation). The user didn't click
// anything here; flashing every keystroke would feel like the picker
// is "fighting" them.
if (!options?.silent) {
setFlash(
next === null
? t('settings.memoryModelInlineFlashCleared')
: t('settings.memoryModelInlineFlashSaved'),
);
}
return true;
} catch {
// Strict providers reject malformed 2xx bodies. Absorb that at this
// UI boundary so both button clicks and debounced re-syncs get one
// visible failure state, never an unhandled rejection.
setSaveError(t('settings.autosaveError'));
return false;
} finally {
pendingPersistCountRef.current -= 1;
if (pendingPersistCountRef.current <= 0) {
pendingPersistCountRef.current = 0;
setBusy(false);
}
}
} finally {
setBusy(false);
}
};
// Serialize every write, including a timer-triggered re-sync, so the
// daemon observes the same order as the user's intent. The tail always
// resolves because `run` turns failure into visible state.
const queued = persistenceQueueRef.current.then(run, run);
persistenceQueueRef.current = queued.then(() => undefined, () => undefined);
return queued;
},
[t],
);
Expand Down Expand Up @@ -387,9 +414,10 @@ export function MemoryModelInline({
const onSelectChange = useCallback(
async (value: string) => {
if (value === SAME_AS_CHAT_SENTINEL) {
setCustomEditing(false);
setCustomDraft('');
await persist(null);
if (await persist(null)) {
setCustomEditing(false);
setCustomDraft('');
}
return;
}
if (value === CUSTOM_MODEL_SENTINEL) {
Expand All @@ -402,8 +430,9 @@ export function MemoryModelInline({
return;
}
if (mode === 'api' && !apiMemoryProvider) return;
setCustomEditing(false);
await persist(buildOverride(value));
if (await persist(buildOverride(value))) {
setCustomEditing(false);
}
},
[mode, apiMemoryProvider, persist, buildOverride, savedModel],
);
Expand All @@ -412,8 +441,9 @@ export function MemoryModelInline({
const trimmed = customDraft.trim();
if (!trimmed) return;
if (mode === 'api' && !apiMemoryProvider) return;
await persist(buildOverride(trimmed));
setCustomEditing(false);
if (await persist(buildOverride(trimmed))) {
setCustomEditing(false);
}
}, [customDraft, mode, apiMemoryProvider, persist, buildOverride]);

// Stable unique id for the labelling span so multiple instances of
Expand Down Expand Up @@ -475,6 +505,15 @@ export function MemoryModelInline({
{flash}
</span>
) : null}
{saveError ? (
<span
role="alert"
aria-live="assertive"
style={{ display: 'inline-block', marginLeft: 8, color: 'var(--text-danger, #b42318)' }}
>
{saveError}
</span>
) : null}
<SearchableModelSelect
aria-labelledby={labelId}
className="inline-switcher__select settings-model-select settings-model-select--byok"
Expand Down
6 changes: 4 additions & 2 deletions apps/web/src/components/MemoryProfilePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,10 @@ function parseProfileBody(body: string): Record<string, string> {
for (const line of lines) {
const m = /^\s*-\s*([^:]+):\s*(.*)$/.exec(line);
if (!m) continue;
const rawLabel = (m[1] ?? '').trim().toLowerCase();
const value = (m[2] ?? '').trim();
// Both groups are guaranteed present once `.exec` matches (`[^:]+` requires
// ≥1 char; `.*` always yields a string), so the captures are non-null here.
const rawLabel = m[1]!.trim().toLowerCase();
const value = m[2]!.trim();
const field = PROFILE_FIELDS.find((f) => f.label.toLowerCase() === rawLabel);
if (field) out[field.label] = value;
}
Expand Down
Loading
Loading