merge(dev): sync dev into main

This commit is contained in:
777genius 2026-05-05 10:42:33 +03:00
commit 5f051cc6f3
6 changed files with 168 additions and 22 deletions

View file

@ -35,7 +35,7 @@ jobs:
NUXT_APP_BASE_URL: /claude_agent_teams_ui/
NUXT_PUBLIC_SITE_URL: https://777genius.github.io/claude_agent_teams_ui
NUXT_PUBLIC_GITHUB_REPO: 777genius/claude_agent_teams_ui
run: npx nuxt generate
run: npm run generate:all
- uses: actions/configure-pages@v5

View file

@ -8,6 +8,7 @@ const siteUrl = process.env.NUXT_PUBLIC_SITE_URL || "https://777genius.github.io
const githubRepo = process.env.NUXT_PUBLIC_GITHUB_REPO || "777genius/claude_agent_teams_ui";
const githubReleasesUrl = `https://github.com/${githubRepo}/releases`;
const baseURL = process.env.NUXT_APP_BASE_URL || "/";
const basePrefixedDocsPath = `${baseURL.replace(/\/?$/, "/")}docs`;
export default defineNuxtConfig({
compatibilityDate: "2026-01-19",
@ -57,7 +58,9 @@ export default defineNuxtConfig({
prerender: {
ignore: [
"/docs",
"/docs/**"
"/docs/**",
basePrefixedDocsPath,
`${basePrefixedDocsPath}/**`
],
routes: [
...generateI18nRoutes(),

View file

@ -4,29 +4,55 @@ import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const {
deleteSnapshotIfMatchesMock,
deleteSnapshotMock,
emptySnapshotMock,
loadSnapshotMock,
migrateLegacyMock,
saveSnapshotMock,
} = vi.hoisted(() => ({
deleteSnapshotMock: vi.fn(),
emptySnapshotMock: vi.fn((teamName: string) => ({
version: 1,
teamName,
text: '',
chips: [],
attachments: [],
actionMode: 'do',
updatedAt: Date.now(),
})),
loadSnapshotMock: vi.fn(),
migrateLegacyMock: vi.fn(),
saveSnapshotMock: vi.fn(),
}));
} = vi.hoisted(() => {
interface MockSnapshot {
version: number;
teamName: string;
text: string;
chips: unknown[];
attachments: unknown[];
actionMode?: string;
pendingSendId?: string;
updatedAt: number;
}
const deleteSnapshot = vi.fn();
const loadSnapshot = vi.fn();
return {
deleteSnapshotIfMatchesMock: vi.fn(
async (teamName: string, predicate: (snapshot: MockSnapshot | null) => boolean) => {
const snapshot = (await loadSnapshot(teamName)) as MockSnapshot | null;
if (predicate(snapshot)) {
await deleteSnapshot(teamName);
}
}
),
deleteSnapshotMock: deleteSnapshot,
emptySnapshotMock: vi.fn((teamName: string) => ({
version: 1,
teamName,
text: '',
chips: [],
attachments: [],
actionMode: 'do',
updatedAt: Date.now(),
})),
loadSnapshotMock: loadSnapshot,
migrateLegacyMock: vi.fn(),
saveSnapshotMock: vi.fn(),
};
});
vi.mock('@renderer/services/composerDraftStorage', () => ({
composerDraftStorage: {
deleteSnapshotIfMatches: deleteSnapshotIfMatchesMock,
deleteSnapshot: deleteSnapshotMock,
emptySnapshot: emptySnapshotMock,
loadSnapshot: loadSnapshotMock,
@ -90,6 +116,7 @@ async function renderLoadedHook(): Promise<{
describe('useComposerDraft pending send lifecycle', () => {
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
deleteSnapshotIfMatchesMock.mockClear();
deleteSnapshotMock.mockReset();
emptySnapshotMock.mockClear();
loadSnapshotMock.mockReset();

View file

@ -571,10 +571,10 @@ export function useComposerDraft(teamName: string): UseComposerDraftResult {
(teamNameForDelete: string, submittedContent?: ComposerDraftContent) =>
enqueuePersist(async () => {
if (submittedContent != null) {
const currentSnapshot = await composerDraftStorage.loadSnapshot(teamNameForDelete);
if (!snapshotMatchesContent(currentSnapshot, submittedContent)) {
return;
}
await composerDraftStorage.deleteSnapshotIfMatches(teamNameForDelete, (snapshot) =>
snapshotMatchesContent(snapshot, submittedContent)
);
return;
}
await composerDraftStorage.deleteSnapshot(teamNameForDelete);
}),

View file

@ -6,7 +6,7 @@
* Drafts persist until explicitly cleared (on send or manual action).
*/
import { del, get, set } from 'idb-keyval';
import { createStore, del, get, promisifyRequest, set } from 'idb-keyval';
import type { InlineChip } from '@renderer/types/inlineChip';
import type { AgentActionMode, AttachmentPayload } from '@shared/types';
@ -34,6 +34,7 @@ export interface ComposerDraftSnapshot {
// ---------------------------------------------------------------------------
const KEY_PREFIX = 'composer:';
const keyvalStore = createStore('keyval-store', 'keyval');
function storageKey(teamName: string): string {
return `${KEY_PREFIX}${teamName}`;
@ -83,6 +84,43 @@ function markIdbUnavailable(): void {
idbUnavailable = true;
}
function toError(error: unknown, fallbackMessage: string): Error {
return error instanceof Error ? error : new Error(fallbackMessage);
}
function deleteMatchingSnapshotInStore(
store: IDBObjectStore,
key: string,
predicate: (snapshot: ComposerDraftSnapshot | null) => boolean
): Promise<void> {
return new Promise<void>((resolve, reject) => {
const request = store.get(key);
request.onerror = () => {
reject(toError(request.error, 'Failed to read composer draft snapshot.'));
};
request.onsuccess = () => {
try {
const raw = request.result as unknown;
const snapshot = raw == null ? null : isValidSnapshot(raw) ? raw : null;
const shouldDelete = (raw != null && snapshot == null) || predicate(snapshot);
if (shouldDelete) {
store.delete(key);
}
void promisifyRequest(store.transaction).then(
() => resolve(),
(error) => reject(toError(error, 'Failed to delete composer draft snapshot.'))
);
} catch (error) {
reject(toError(error, 'Failed to evaluate composer draft snapshot.'));
}
};
});
}
// ---------------------------------------------------------------------------
// Core API
// ---------------------------------------------------------------------------
@ -133,6 +171,26 @@ async function deleteSnapshot(teamName: string): Promise<void> {
}
}
async function deleteSnapshotIfMatches(
teamName: string,
predicate: (snapshot: ComposerDraftSnapshot | null) => boolean
): Promise<void> {
const key = storageKey(teamName);
if (idbUnavailable) {
const snapshot = fallbackStore.get(key) ?? null;
if (predicate(snapshot)) fallbackStore.delete(key);
return;
}
try {
await keyvalStore('readwrite', (store) => deleteMatchingSnapshotInStore(store, key, predicate));
} catch {
markIdbUnavailable();
const snapshot = fallbackStore.get(key) ?? null;
if (predicate(snapshot)) fallbackStore.delete(key);
}
}
// ---------------------------------------------------------------------------
// Legacy migration
// ---------------------------------------------------------------------------
@ -267,6 +325,7 @@ export const composerDraftStorage = {
saveSnapshot,
loadSnapshot,
deleteSnapshot,
deleteSnapshotIfMatches,
migrateLegacy,
emptySnapshot,
};

View file

@ -4,6 +4,35 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const store = new Map<string, unknown>();
vi.mock('idb-keyval', () => ({
createStore: vi.fn(
() =>
async <T>(
_txMode: IDBTransactionMode,
callback: (objectStore: IDBObjectStore) => T | PromiseLike<T>
): Promise<T> => {
const objectStore = {
transaction: {},
get: (key: string) => {
const request = {
result: store.get(key) ?? undefined,
error: null,
onsuccess: null as (() => void) | null,
onerror: null as (() => void) | null,
};
queueMicrotask(() => {
request.onsuccess?.();
});
return request;
},
delete: (key: string) => {
store.delete(key);
},
};
return callback(objectStore as unknown as IDBObjectStore);
}
),
promisifyRequest: vi.fn(() => Promise.resolve(undefined)),
get: vi.fn((key: string) => Promise.resolve(store.get(key) ?? undefined)),
set: vi.fn((key: string, value: unknown) => {
store.set(key, value);
@ -91,6 +120,34 @@ describe('composerDraftStorage', () => {
});
});
describe('deleteSnapshotIfMatches', () => {
it('should delete a snapshot when the predicate matches', async () => {
await composerDraftStorage.saveSnapshot(
'team-a',
makeSnapshot('team-a', { pendingSendId: 'send-1' })
);
await composerDraftStorage.deleteSnapshotIfMatches(
'team-a',
(snapshot) => snapshot?.pendingSendId === 'send-1'
);
expect(await composerDraftStorage.loadSnapshot('team-a')).toBeNull();
});
it('should keep a snapshot when the predicate does not match', async () => {
const snap = makeSnapshot('team-a', { pendingSendId: 'newer-send' });
await composerDraftStorage.saveSnapshot('team-a', snap);
await composerDraftStorage.deleteSnapshotIfMatches(
'team-a',
(snapshot) => snapshot?.pendingSendId === 'older-send'
);
expect(await composerDraftStorage.loadSnapshot('team-a')).toEqual(snap);
});
});
describe('team isolation', () => {
it('should isolate drafts by teamName', async () => {
const snapA = makeSnapshot('team-a', { text: 'from team A' });