Compare commits
19 Commits
v1.5.0-ref
...
v1.6.5-ses
| Author | SHA1 | Date | |
|---|---|---|---|
| c750ce9e62 | |||
| bbf9fac936 | |||
| 6fa6eb7f32 | |||
| 5932682193 | |||
| 9d0d41bcb3 | |||
| e167f851fd | |||
| f6c7e12dbf | |||
| 6a9fe187bd | |||
| 943ae7df03 | |||
| 4b5b9b2cb3 | |||
| 273eeac68c | |||
| cd897d6893 | |||
| a643b5f67f | |||
| 57c883b775 | |||
| 4a9f207fe8 | |||
| 782c2b183d | |||
| 7f0fd1281b | |||
| 2f6be39efd | |||
| 1ecb79476e |
@@ -3,5 +3,6 @@ PORT=3000
|
|||||||
DATABASE_URL=postgres://boocode:CHANGE_ME@boocode_db:5432/boocode
|
DATABASE_URL=postgres://boocode:CHANGE_ME@boocode_db:5432/boocode
|
||||||
LLAMA_SWAP_URL=http://100.101.41.16:8401
|
LLAMA_SWAP_URL=http://100.101.41.16:8401
|
||||||
PROJECT_ROOT_WHITELIST=/opt
|
PROJECT_ROOT_WHITELIST=/opt
|
||||||
|
BOOTSTRAP_ROOT=/opt/projects
|
||||||
DEFAULT_MODEL=qwen3.6-35b-a3b-mxfp4
|
DEFAULT_MODEL=qwen3.6-35b-a3b-mxfp4
|
||||||
POSTGRES_PASSWORD=CHANGE_ME
|
POSTGRES_PASSWORD=CHANGE_ME
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,3 +5,4 @@ dist
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
.vite
|
.vite
|
||||||
coverage
|
coverage
|
||||||
|
secrets/
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ Position-shift pattern for panes (legacy `session_panes` table): negate-and-rest
|
|||||||
|
|
||||||
## Environment
|
## Environment
|
||||||
|
|
||||||
Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0.0.0), `PROJECT_ROOT_WHITELIST` (/opt), `DEFAULT_MODEL`, `LOG_LEVEL`.
|
Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0.0.0), `PROJECT_ROOT_WHITELIST` (/opt, read-only scope for add-existing path resolution), `BOOTSTRAP_ROOT` (/opt/projects, writable scope for create-new-project bootstrap mkdir target — host must `mkdir -p /opt/projects` before container start), `DEFAULT_MODEL`, `LOG_LEVEL`.
|
||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ RUN pnpm deploy --filter=@boocode/server --prod --legacy /out/server
|
|||||||
|
|
||||||
|
|
||||||
FROM node:20-alpine AS runtime
|
FROM node:20-alpine AS runtime
|
||||||
RUN apk add --no-cache ripgrep
|
RUN apk add --no-cache ripgrep git openssh-client
|
||||||
|
RUN mkdir -p /root/.ssh && ssh-keyscan -p 2222 -H 100.114.205.53 git.indifferentketchup.com >> /root/.ssh/known_hosts && chmod 700 /root/.ssh && chmod 600 /root/.ssh/known_hosts
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY --from=builder /out/server ./
|
COPY --from=builder /out/server ./
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx watch src/index.ts",
|
"dev": "tsx watch src/index.ts",
|
||||||
"build": "tsc && node -e \"import('node:fs').then(fs=>fs.copyFileSync('src/schema.sql','dist/schema.sql'))\"",
|
"build": "tsc && node -e \"import('node:fs').then(fs=>fs.copyFileSync('src/schema.sql','dist/schema.sql'))\"",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/static": "^7.0.4",
|
"@fastify/static": "^7.0.4",
|
||||||
@@ -21,6 +22,7 @@
|
|||||||
"@types/node": "^20.14.10",
|
"@types/node": "^20.14.10",
|
||||||
"@types/ws": "^8.5.10",
|
"@types/ws": "^8.5.10",
|
||||||
"tsx": "^4.16.2",
|
"tsx": "^4.16.2",
|
||||||
"typescript": "^5.5.0"
|
"typescript": "^5.5.0",
|
||||||
|
"vitest": "^3.2.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const ConfigSchema = z.object({
|
|||||||
DATABASE_URL: z.string().url(),
|
DATABASE_URL: z.string().url(),
|
||||||
LLAMA_SWAP_URL: z.string().url(),
|
LLAMA_SWAP_URL: z.string().url(),
|
||||||
PROJECT_ROOT_WHITELIST: z.string().default('/opt'),
|
PROJECT_ROOT_WHITELIST: z.string().default('/opt'),
|
||||||
|
BOOTSTRAP_ROOT: z.string().default('/opt/projects'),
|
||||||
DEFAULT_MODEL: z.string().default('qwen3.6-35b-a3b-mxfp4'),
|
DEFAULT_MODEL: z.string().default('qwen3.6-35b-a3b-mxfp4'),
|
||||||
LOG_LEVEL: z.string().default('info'),
|
LOG_LEVEL: z.string().default('info'),
|
||||||
GITEA_BASE_URL: z.string().url().default('https://git.indifferentketchup.com'),
|
GITEA_BASE_URL: z.string().url().default('https://git.indifferentketchup.com'),
|
||||||
|
|||||||
92
apps/server/src/routes/__tests__/projects.test.ts
Normal file
92
apps/server/src/routes/__tests__/projects.test.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||||
|
import { mkdtemp, mkdir, rm, realpath, symlink, writeFile } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { resolveProjectPath } from '../projects.js';
|
||||||
|
|
||||||
|
describe('resolveProjectPath', () => {
|
||||||
|
let scratch: string;
|
||||||
|
let whitelist: string;
|
||||||
|
let outside: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
// mkdtemp returns a real path on most platforms, but symlink-resolve it
|
||||||
|
// anyway to defeat /tmp -> /private/tmp style indirection on macOS, and
|
||||||
|
// keep this stable for the comparisons below.
|
||||||
|
scratch = await realpath(await mkdtemp(join(tmpdir(), 'boocode-projects-test-')));
|
||||||
|
whitelist = join(scratch, 'wl');
|
||||||
|
outside = join(scratch, 'other');
|
||||||
|
await mkdir(whitelist, { recursive: true });
|
||||||
|
await mkdir(outside, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (scratch) {
|
||||||
|
await rm(scratch, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns real path and basename for a valid subdirectory under the whitelist', async () => {
|
||||||
|
const projectDir = join(whitelist, 'my-project');
|
||||||
|
await mkdir(projectDir);
|
||||||
|
const result = await resolveProjectPath(projectDir, whitelist);
|
||||||
|
expect('error' in result).toBe(false);
|
||||||
|
if ('error' in result) return; // narrow
|
||||||
|
expect(result.real).toBe(projectDir);
|
||||||
|
expect(result.name).toBe('my-project');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a path outside the whitelist', async () => {
|
||||||
|
const projectDir = join(outside, 'foo');
|
||||||
|
await mkdir(projectDir);
|
||||||
|
const result = await resolveProjectPath(projectDir, whitelist);
|
||||||
|
expect('error' in result).toBe(true);
|
||||||
|
if (!('error' in result)) return;
|
||||||
|
expect(result.error.toLowerCase()).toContain('path must be under');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects relative paths', async () => {
|
||||||
|
const result = await resolveProjectPath('relative/path', whitelist);
|
||||||
|
expect('error' in result).toBe(true);
|
||||||
|
if (!('error' in result)) return;
|
||||||
|
expect(result.error.toLowerCase()).toContain('absolute');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects nonexistent paths', async () => {
|
||||||
|
const result = await resolveProjectPath('/nonexistent/foo-boocode-test', whitelist);
|
||||||
|
expect('error' in result).toBe(true);
|
||||||
|
if (!('error' in result)) return;
|
||||||
|
expect(result.error.toLowerCase()).toContain('does not exist');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects symlink escapes (realpath resolution catches traversal)', async () => {
|
||||||
|
// Create a symlink INSIDE the whitelist that points OUTSIDE. realpath
|
||||||
|
// should resolve through it and the resulting real path should fail the
|
||||||
|
// whitelist scope check.
|
||||||
|
const escapeLink = join(whitelist, 'escape-link');
|
||||||
|
await symlink(outside, escapeLink);
|
||||||
|
const result = await resolveProjectPath(escapeLink, whitelist);
|
||||||
|
expect('error' in result).toBe(true);
|
||||||
|
if (!('error' in result)) return;
|
||||||
|
expect(result.error.toLowerCase()).toContain('path must be under');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects the whitelist directory itself as a project root', async () => {
|
||||||
|
// A project's parent can't be the project. The scope check must require
|
||||||
|
// the candidate path to be strictly below the whitelist (whitelist + sep
|
||||||
|
// prefix), not just equal to it.
|
||||||
|
const result = await resolveProjectPath(whitelist, whitelist);
|
||||||
|
expect('error' in result).toBe(true);
|
||||||
|
if (!('error' in result)) return;
|
||||||
|
expect(result.error.toLowerCase()).toContain('path must be under');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects non-directory targets (file under whitelist)', async () => {
|
||||||
|
const filePath = join(whitelist, 'a-file.txt');
|
||||||
|
await writeFile(filePath, 'content', 'utf8');
|
||||||
|
const result = await resolveProjectPath(filePath, whitelist);
|
||||||
|
expect('error' in result).toBe(true);
|
||||||
|
if (!('error' in result)) return;
|
||||||
|
expect(result.error.toLowerCase()).toContain('not a directory');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -41,7 +41,7 @@ async function isDir(path: string): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveProjectPath(
|
export async function resolveProjectPath(
|
||||||
raw: string,
|
raw: string,
|
||||||
whitelist: string
|
whitelist: string
|
||||||
): Promise<{ real: string; name: string } | { error: string }> {
|
): Promise<{ real: string; name: string } | { error: string }> {
|
||||||
@@ -53,7 +53,7 @@ async function resolveProjectPath(
|
|||||||
return { error: 'path does not exist' };
|
return { error: 'path does not exist' };
|
||||||
}
|
}
|
||||||
const whitelistReal = await realpath(whitelist);
|
const whitelistReal = await realpath(whitelist);
|
||||||
if (real !== whitelistReal && !real.startsWith(whitelistReal + sep)) {
|
if (!real.startsWith(whitelistReal + sep)) {
|
||||||
return { error: `path must be under ${whitelist}` };
|
return { error: `path must be under ${whitelist}` };
|
||||||
}
|
}
|
||||||
if (!(await isDir(real))) return { error: 'path is not a directory' };
|
if (!(await isDir(real))) return { error: 'path is not a directory' };
|
||||||
|
|||||||
@@ -120,6 +120,15 @@ export function registerSessionRoutes(
|
|||||||
return { error: 'invalid body', details: parsed.error.flatten() };
|
return { error: 'invalid body', details: parsed.error.flatten() };
|
||||||
}
|
}
|
||||||
const { name, model, system_prompt } = parsed.data;
|
const { name, model, system_prompt } = parsed.data;
|
||||||
|
// Read the prior name so the post-update publish can skip no-op renames
|
||||||
|
// (PATCH { name: "Foo" } where the session is already "Foo"). The window
|
||||||
|
// between SELECT and UPDATE is sub-millisecond in the same request handler;
|
||||||
|
// a concurrent rename in that gap would just mean one stale publish, which
|
||||||
|
// existing clients dedup by id.
|
||||||
|
const before = await sql<{ name: string }[]>`
|
||||||
|
SELECT name FROM sessions WHERE id = ${req.params.id}
|
||||||
|
`;
|
||||||
|
const priorName = before[0]?.name;
|
||||||
const rows = await sql<Session[]>`
|
const rows = await sql<Session[]>`
|
||||||
UPDATE sessions
|
UPDATE sessions
|
||||||
SET
|
SET
|
||||||
@@ -135,7 +144,7 @@ export function registerSessionRoutes(
|
|||||||
return { error: 'session not found' };
|
return { error: 'session not found' };
|
||||||
}
|
}
|
||||||
const session = rows[0]!;
|
const session = rows[0]!;
|
||||||
if (name !== undefined) {
|
if (name !== undefined && session.name !== priorName) {
|
||||||
broker.publishUser('default', {
|
broker.publishUser('default', {
|
||||||
type: 'session_renamed',
|
type: 'session_renamed',
|
||||||
session_id: session.id,
|
session_id: session.id,
|
||||||
|
|||||||
237
apps/server/src/services/__tests__/inference.test.ts
Normal file
237
apps/server/src/services/__tests__/inference.test.ts
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { buildMessagesPayload } from '../inference.js';
|
||||||
|
import type {
|
||||||
|
Message,
|
||||||
|
MessageRole,
|
||||||
|
Project,
|
||||||
|
Session,
|
||||||
|
ToolCall,
|
||||||
|
ToolResult,
|
||||||
|
} from '../../types/api.js';
|
||||||
|
|
||||||
|
// ---- fixtures ---------------------------------------------------------------
|
||||||
|
|
||||||
|
function makeSession(overrides: Partial<Session> = {}): Session {
|
||||||
|
return {
|
||||||
|
id: 'sess',
|
||||||
|
project_id: 'proj',
|
||||||
|
name: 'test session',
|
||||||
|
model: 'test-model',
|
||||||
|
system_prompt: '',
|
||||||
|
status: 'open',
|
||||||
|
created_at: new Date(0).toISOString(),
|
||||||
|
updated_at: new Date(0).toISOString(),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeProject(overrides: Partial<Project> = {}): Project {
|
||||||
|
return {
|
||||||
|
id: 'proj',
|
||||||
|
name: 'test project',
|
||||||
|
path: '/tmp/proj',
|
||||||
|
added_at: new Date(0).toISOString(),
|
||||||
|
last_session_id: null,
|
||||||
|
status: 'open',
|
||||||
|
gitea_remote: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let counter = 0;
|
||||||
|
function makeMessage(
|
||||||
|
role: MessageRole,
|
||||||
|
content: string,
|
||||||
|
overrides: Partial<Message> = {}
|
||||||
|
): Message {
|
||||||
|
counter += 1;
|
||||||
|
return {
|
||||||
|
id: `m${counter}`,
|
||||||
|
session_id: 'sess',
|
||||||
|
chat_id: 'chat',
|
||||||
|
role,
|
||||||
|
content,
|
||||||
|
kind: 'message',
|
||||||
|
tool_calls: null,
|
||||||
|
tool_results: null,
|
||||||
|
status: 'complete',
|
||||||
|
last_seq: 0,
|
||||||
|
tokens_used: null,
|
||||||
|
ctx_used: null,
|
||||||
|
ctx_max: null,
|
||||||
|
started_at: null,
|
||||||
|
finished_at: null,
|
||||||
|
created_at: new Date(counter * 1000).toISOString(),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- tests ------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('buildMessagesPayload', () => {
|
||||||
|
it('prepends a system prompt containing the project path', () => {
|
||||||
|
const session = makeSession();
|
||||||
|
const project = makeProject({ path: '/tmp/my-proj' });
|
||||||
|
const result = buildMessagesPayload(session, project, []);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]!.role).toBe('system');
|
||||||
|
expect(result[0]!.content).toContain('/tmp/my-proj');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends session.system_prompt to the system message when set', () => {
|
||||||
|
const session = makeSession({ system_prompt: 'Be terse.' });
|
||||||
|
const project = makeProject();
|
||||||
|
const result = buildMessagesPayload(session, project, []);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]!.role).toBe('system');
|
||||||
|
expect(result[0]!.content).toContain('Be terse.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns user/assistant messages in order when no compact marker is present', () => {
|
||||||
|
const session = makeSession();
|
||||||
|
const project = makeProject();
|
||||||
|
const history: Message[] = [
|
||||||
|
makeMessage('user', 'hi'),
|
||||||
|
makeMessage('assistant', 'hello'),
|
||||||
|
makeMessage('user', 'how are you'),
|
||||||
|
makeMessage('assistant', 'great'),
|
||||||
|
];
|
||||||
|
const result = buildMessagesPayload(session, project, history);
|
||||||
|
// 1 system + 4 history messages
|
||||||
|
expect(result).toHaveLength(5);
|
||||||
|
expect(result[0]!.role).toBe('system');
|
||||||
|
expect(result[1]).toMatchObject({ role: 'user', content: 'hi' });
|
||||||
|
expect(result[2]).toMatchObject({ role: 'assistant', content: 'hello' });
|
||||||
|
expect(result[3]).toMatchObject({ role: 'user', content: 'how are you' });
|
||||||
|
expect(result[4]).toMatchObject({ role: 'assistant', content: 'great' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts from the latest compact marker, emitting it as a system message', () => {
|
||||||
|
const session = makeSession();
|
||||||
|
const project = makeProject();
|
||||||
|
const history: Message[] = [
|
||||||
|
makeMessage('user', 'old1'),
|
||||||
|
makeMessage('assistant', 'oldreply1'),
|
||||||
|
makeMessage('user', 'old2'),
|
||||||
|
makeMessage('assistant', 'compacted summary text', { kind: 'compact' }),
|
||||||
|
makeMessage('user', 'new1'),
|
||||||
|
makeMessage('assistant', 'newreply1'),
|
||||||
|
];
|
||||||
|
const result = buildMessagesPayload(session, project, history);
|
||||||
|
// Expect: leading base-system prompt, then the compact as system, then
|
||||||
|
// the user/assistant pair following it.
|
||||||
|
expect(result).toHaveLength(4);
|
||||||
|
expect(result[0]!.role).toBe('system');
|
||||||
|
expect(result[1]).toMatchObject({
|
||||||
|
role: 'system',
|
||||||
|
content: 'compacted summary text',
|
||||||
|
});
|
||||||
|
expect(result[2]).toMatchObject({ role: 'user', content: 'new1' });
|
||||||
|
expect(result[3]).toMatchObject({ role: 'assistant', content: 'newreply1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses only the most recent compact when multiple are present', () => {
|
||||||
|
const session = makeSession();
|
||||||
|
const project = makeProject();
|
||||||
|
const history: Message[] = [
|
||||||
|
makeMessage('user', 'u1'),
|
||||||
|
makeMessage('assistant', 'first compact summary', { kind: 'compact' }),
|
||||||
|
makeMessage('user', 'u2'),
|
||||||
|
makeMessage('assistant', 'second compact summary', { kind: 'compact' }),
|
||||||
|
makeMessage('user', 'u3'),
|
||||||
|
makeMessage('assistant', 'final reply'),
|
||||||
|
];
|
||||||
|
const result = buildMessagesPayload(session, project, history);
|
||||||
|
// Expect: base system + latest compact as system + the two messages
|
||||||
|
// following it. The earlier compact and pre-compact history are dropped.
|
||||||
|
expect(result).toHaveLength(4);
|
||||||
|
expect(result[0]!.role).toBe('system');
|
||||||
|
expect(result[1]).toMatchObject({
|
||||||
|
role: 'system',
|
||||||
|
content: 'second compact summary',
|
||||||
|
});
|
||||||
|
expect(result[2]).toMatchObject({ role: 'user', content: 'u3' });
|
||||||
|
expect(result[3]).toMatchObject({ role: 'assistant', content: 'final reply' });
|
||||||
|
// None of the earlier content should leak through
|
||||||
|
const concatenated = result.map((m) => m.content ?? '').join(' ');
|
||||||
|
expect(concatenated).not.toContain('first compact summary');
|
||||||
|
expect(concatenated).not.toContain('u1');
|
||||||
|
expect(concatenated).not.toContain('u2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips streaming and cancelled assistant rows', () => {
|
||||||
|
const session = makeSession();
|
||||||
|
const project = makeProject();
|
||||||
|
const history: Message[] = [
|
||||||
|
makeMessage('user', 'hi'),
|
||||||
|
makeMessage('assistant', 'partial...', { status: 'streaming' }),
|
||||||
|
makeMessage('assistant', 'cancelled fragment', { status: 'cancelled' }),
|
||||||
|
makeMessage('assistant', 'final answer'),
|
||||||
|
];
|
||||||
|
const result = buildMessagesPayload(session, project, history);
|
||||||
|
// 1 system + 1 user + 1 assistant (only the complete one)
|
||||||
|
expect(result).toHaveLength(3);
|
||||||
|
expect(result[1]).toMatchObject({ role: 'user', content: 'hi' });
|
||||||
|
expect(result[2]).toMatchObject({ role: 'assistant', content: 'final answer' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trips an assistant-with-tool_calls followed by its tool result', () => {
|
||||||
|
const session = makeSession();
|
||||||
|
const project = makeProject();
|
||||||
|
const toolCall: ToolCall = {
|
||||||
|
id: 'call_abc',
|
||||||
|
name: 'view_file',
|
||||||
|
args: { path: 'src/index.ts' },
|
||||||
|
};
|
||||||
|
const toolResult: ToolResult = {
|
||||||
|
tool_call_id: 'call_abc',
|
||||||
|
output: { contents: 'console.log(1)' },
|
||||||
|
truncated: false,
|
||||||
|
};
|
||||||
|
const history: Message[] = [
|
||||||
|
makeMessage('user', 'show me the file'),
|
||||||
|
makeMessage('assistant', '', { tool_calls: [toolCall] }),
|
||||||
|
makeMessage('tool', '', { tool_results: toolResult }),
|
||||||
|
makeMessage('assistant', 'here it is'),
|
||||||
|
];
|
||||||
|
const result = buildMessagesPayload(session, project, history);
|
||||||
|
// 1 system + 1 user + 1 assistant(tool_calls) + 1 tool + 1 assistant
|
||||||
|
expect(result).toHaveLength(5);
|
||||||
|
expect(result[1]).toMatchObject({ role: 'user', content: 'show me the file' });
|
||||||
|
expect(result[2]!.role).toBe('assistant');
|
||||||
|
expect(result[2]!.tool_calls).toBeDefined();
|
||||||
|
expect(result[2]!.tool_calls).toHaveLength(1);
|
||||||
|
expect(result[2]!.tool_calls![0]).toMatchObject({
|
||||||
|
id: 'call_abc',
|
||||||
|
type: 'function',
|
||||||
|
function: { name: 'view_file' },
|
||||||
|
});
|
||||||
|
// The OpenAI shape stringifies args.
|
||||||
|
expect(result[2]!.tool_calls![0]!.function.arguments).toBe(
|
||||||
|
JSON.stringify({ path: 'src/index.ts' })
|
||||||
|
);
|
||||||
|
// assistant with empty content should be serialized as content: null
|
||||||
|
expect(result[2]!.content).toBeNull();
|
||||||
|
expect(result[3]).toMatchObject({
|
||||||
|
role: 'tool',
|
||||||
|
tool_call_id: 'call_abc',
|
||||||
|
});
|
||||||
|
// Non-string tool output is JSON-stringified.
|
||||||
|
expect(result[3]!.content).toBe(JSON.stringify({ contents: 'console.log(1)' }));
|
||||||
|
expect(result[4]).toMatchObject({ role: 'assistant', content: 'here it is' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips tool rows with no tool_results', () => {
|
||||||
|
const session = makeSession();
|
||||||
|
const project = makeProject();
|
||||||
|
const history: Message[] = [
|
||||||
|
makeMessage('user', 'do it'),
|
||||||
|
makeMessage('tool', '', { tool_results: null }),
|
||||||
|
makeMessage('assistant', 'done'),
|
||||||
|
];
|
||||||
|
const result = buildMessagesPayload(session, project, history);
|
||||||
|
// 1 system + 1 user + 1 assistant; the empty tool row is dropped.
|
||||||
|
expect(result).toHaveLength(3);
|
||||||
|
expect(result.find((m) => m.role === 'tool')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
47
apps/server/src/services/__tests__/project_bootstrap.test.ts
Normal file
47
apps/server/src/services/__tests__/project_bootstrap.test.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { sanitizeFolderName } from '../project_bootstrap.js';
|
||||||
|
|
||||||
|
describe('sanitizeFolderName', () => {
|
||||||
|
it('passes through a normal slug-like name', () => {
|
||||||
|
expect(sanitizeFolderName('my-project')).toBe('my-project');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lowercases and replaces whitespace with hyphens', () => {
|
||||||
|
expect(sanitizeFolderName('Hello World')).toBe('hello-world');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips path-traversal characters', () => {
|
||||||
|
// dots and slashes fall outside [a-z0-9-] and are removed entirely.
|
||||||
|
expect(sanitizeFolderName('../etc/passwd')).toBe('etcpasswd');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips trailing and leading dots and slashes', () => {
|
||||||
|
expect(sanitizeFolderName('./foo/')).toBe('foo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collapses runs of hyphens and strips leading/trailing ones', () => {
|
||||||
|
expect(sanitizeFolderName('---foo---')).toBe('foo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty string when nothing survives sanitization', () => {
|
||||||
|
// NOTE: sanitizeFolderName itself does NOT throw — it returns ''. The
|
||||||
|
// BootstrapNameError is raised by the caller (bootstrapProject) when the
|
||||||
|
// sanitized result fails the SAFE_NAME regex. The spec's "throws" phrasing
|
||||||
|
// refers to that caller-level validation, not this pure function.
|
||||||
|
expect(sanitizeFolderName('...')).toBe('');
|
||||||
|
expect(sanitizeFolderName(' ')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips control characters and null bytes', () => {
|
||||||
|
// Null bytes and control characters are not in [a-z0-9-] so they're
|
||||||
|
// filtered out (effectively rejected as folder-name content).
|
||||||
|
expect(sanitizeFolderName('my\x00proj\x01')).toBe('myproj');
|
||||||
|
expect(sanitizeFolderName('foo\x00bar')).toBe('foobar');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('truncates names longer than 64 characters', () => {
|
||||||
|
const long = 'a'.repeat(100);
|
||||||
|
expect(sanitizeFolderName(long)).toBe('a'.repeat(64));
|
||||||
|
expect(sanitizeFolderName(long)).toHaveLength(64);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -33,6 +33,7 @@ async function snapMtimes(root: string): Promise<MtimeSnap> {
|
|||||||
const rootStat = await fs.stat(root);
|
const rootStat = await fs.stat(root);
|
||||||
let gitHead: number | null = null;
|
let gitHead: number | null = null;
|
||||||
let gitIndex: number | null = null;
|
let gitIndex: number | null = null;
|
||||||
|
// best-effort; ignore failure because the project may not be a git repo
|
||||||
try { gitHead = (await fs.stat(path.join(root, '.git', 'HEAD'))).mtimeMs; } catch {}
|
try { gitHead = (await fs.stat(path.join(root, '.git', 'HEAD'))).mtimeMs; } catch {}
|
||||||
try { gitIndex = (await fs.stat(path.join(root, '.git', 'index'))).mtimeMs; } catch {}
|
try { gitIndex = (await fs.stat(path.join(root, '.git', 'index'))).mtimeMs; } catch {}
|
||||||
return { root: rootStat.mtimeMs, gitHead, gitIndex };
|
return { root: rootStat.mtimeMs, gitHead, gitIndex };
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const BASE_SYSTEM_PROMPT = (projectPath: string) =>
|
|||||||
`You are BooCode Chat, a code investigation assistant. The user is working on a project located at ${projectPath}. Use the file-read tools (view_file, list_dir, grep, find_files) to investigate code when needed. Be concise. Cite file paths and line numbers when discussing code. Do not hallucinate file contents — read the file first. Tool results may be truncated; if so, narrow your query rather than guessing.`;
|
`You are BooCode Chat, a code investigation assistant. The user is working on a project located at ${projectPath}. Use the file-read tools (view_file, list_dir, grep, find_files) to investigate code when needed. Be concise. Cite file paths and line numbers when discussing code. Do not hallucinate file contents — read the file first. Tool results may be truncated; if so, narrow your query rather than guessing.`;
|
||||||
|
|
||||||
const DB_FLUSH_INTERVAL_MS = 500;
|
const DB_FLUSH_INTERVAL_MS = 500;
|
||||||
const MAX_TOOL_LOOP_DEPTH = 5;
|
const MAX_TOOL_LOOP_DEPTH = 15;
|
||||||
|
|
||||||
export interface InferenceFrame {
|
export interface InferenceFrame {
|
||||||
type:
|
type:
|
||||||
|
|||||||
@@ -82,11 +82,13 @@ export async function bootstrapProject(
|
|||||||
throw new BootstrapNameError(`invalid name after sanitization: "${folder}"`);
|
throw new BootstrapNameError(`invalid name after sanitization: "${folder}"`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Whitelist resolution
|
// Bootstrap target resolution. Uses BOOTSTRAP_ROOT (writable), not
|
||||||
const whitelistReal = await realpath(config.PROJECT_ROOT_WHITELIST);
|
// PROJECT_ROOT_WHITELIST (which may be a wider read-only scope for
|
||||||
const fullPath = resolve(whitelistReal, folder);
|
// add-existing flow).
|
||||||
if (!fullPath.startsWith(whitelistReal + sep)) {
|
const bootstrapReal = await realpath(config.BOOTSTRAP_ROOT);
|
||||||
throw new BootstrapPathError('path escapes whitelist');
|
const fullPath = resolve(bootstrapReal, folder);
|
||||||
|
if (!fullPath.startsWith(bootstrapReal + sep)) {
|
||||||
|
throw new BootstrapPathError('path escapes bootstrap root');
|
||||||
}
|
}
|
||||||
if (existsSync(fullPath)) {
|
if (existsSync(fullPath)) {
|
||||||
throw new BootstrapCollisionError(`path already exists: ${fullPath}`);
|
throw new BootstrapCollisionError(`path already exists: ${fullPath}`);
|
||||||
@@ -144,7 +146,8 @@ export async function bootstrapProject(
|
|||||||
|
|
||||||
// Step 6: git remote add + push
|
// Step 6: git remote add + push
|
||||||
try {
|
try {
|
||||||
await execFileAsync('git', ['remote', 'add', 'origin', repo.ssh_url], { cwd: fullPath });
|
const sshUrl = repo.ssh_url.replace('git.indifferentketchup.com', '100.114.205.53');
|
||||||
|
await execFileAsync('git', ['remote', 'add', 'origin', sshUrl], { cwd: fullPath });
|
||||||
await execFileAsync('git', ['push', '-u', 'origin', 'main'], { cwd: fullPath });
|
await execFileAsync('git', ['push', '-u', 'origin', 'main'], { cwd: fullPath });
|
||||||
gitea_pushed = true;
|
gitea_pushed = true;
|
||||||
log.info({ folder }, 'project_bootstrap: pushed to gitea');
|
log.info({ folder }, 'project_bootstrap: pushed to gitea');
|
||||||
|
|||||||
@@ -10,5 +10,6 @@
|
|||||||
"declaration": false,
|
"declaration": false,
|
||||||
"sourceMap": true
|
"sourceMap": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"]
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["src/**/__tests__/**", "**/*.test.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
9
apps/server/vitest.config.ts
Normal file
9
apps/server/vitest.config.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'node',
|
||||||
|
globals: false,
|
||||||
|
include: ['src/**/__tests__/**/*.test.ts'],
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -8,6 +8,9 @@ import { Project } from '@/pages/Project';
|
|||||||
import { Session } from '@/pages/Session';
|
import { Session } from '@/pages/Session';
|
||||||
import { Toaster } from '@/components/ui/sonner';
|
import { Toaster } from '@/components/ui/sonner';
|
||||||
import { useUserEvents } from '@/hooks/useUserEvents';
|
import { useUserEvents } from '@/hooks/useUserEvents';
|
||||||
|
import { SidebarDrawerProvider, useSidebarDrawer } from '@/hooks/useSidebarDrawer';
|
||||||
|
import { RightRailDrawerProvider, useRightRailDrawer } from '@/hooks/useRightRailDrawer';
|
||||||
|
import { useViewport } from '@/hooks/useViewport';
|
||||||
|
|
||||||
function SessionRightRail() {
|
function SessionRightRail() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
@@ -18,17 +21,51 @@ function SessionRightRail() {
|
|||||||
function RightRailForSession({ sessionId }: { sessionId: string }) {
|
function RightRailForSession({ sessionId }: { sessionId: string }) {
|
||||||
const [projectId, setProjectId] = useState<string | null>(null);
|
const [projectId, setProjectId] = useState<string | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.sessions.get(sessionId).then((s) => setProjectId(s.project_id)).catch(() => {});
|
api.sessions
|
||||||
|
.get(sessionId)
|
||||||
|
.then((s) => setProjectId(s.project_id))
|
||||||
|
.catch((err) => console.warn('RightRail: failed to fetch session', err));
|
||||||
}, [sessionId]);
|
}, [sessionId]);
|
||||||
if (!projectId) return null;
|
if (!projectId) return null;
|
||||||
|
// v1.6.2: rendered on all viewports. On mobile, RightRail itself renders as
|
||||||
|
// a right-side drawer toggled by the header's FolderTree button (via
|
||||||
|
// useRightRailDrawer). On desktop, it renders inline as before with its
|
||||||
|
// own internal open/close state.
|
||||||
return <RightRail projectId={projectId} />;
|
return <RightRail projectId={projectId} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MobileBackdrop() {
|
||||||
|
const { open, setOpen } = useSidebarDrawer();
|
||||||
|
const { isMobile } = useViewport();
|
||||||
|
if (!isMobile || !open) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-30 bg-black/40 md:hidden"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MobileRightRailBackdrop() {
|
||||||
|
const { open, setOpen } = useRightRailDrawer();
|
||||||
|
const { isMobile } = useViewport();
|
||||||
|
if (!isMobile || !open) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-30 bg-black/40 md:hidden"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function AppShell() {
|
function AppShell() {
|
||||||
useUserEvents();
|
useUserEvents();
|
||||||
return (
|
return (
|
||||||
<div className="dark h-screen flex bg-background text-foreground">
|
<div className="dark h-screen flex bg-background text-foreground">
|
||||||
<ProjectSidebar />
|
<ProjectSidebar />
|
||||||
|
<MobileBackdrop />
|
||||||
<main className="flex-1 flex flex-col min-w-0">
|
<main className="flex-1 flex flex-col min-w-0">
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Home />} />
|
<Route path="/" element={<Home />} />
|
||||||
@@ -36,6 +73,7 @@ function AppShell() {
|
|||||||
<Route path="/session/:id" element={<Session />} />
|
<Route path="/session/:id" element={<Session />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
|
<MobileRightRailBackdrop />
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/session/:id" element={<SessionRightRail />} />
|
<Route path="/session/:id" element={<SessionRightRail />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
@@ -47,7 +85,11 @@ function AppShell() {
|
|||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<AppShell />
|
<SidebarDrawerProvider>
|
||||||
|
<RightRailDrawerProvider>
|
||||||
|
<AppShell />
|
||||||
|
</RightRailDrawerProvider>
|
||||||
|
</SidebarDrawerProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -191,6 +191,5 @@ export type WsFrame =
|
|||||||
finished_at?: string | null;
|
finished_at?: string | null;
|
||||||
}
|
}
|
||||||
| { type: 'messages_deleted'; message_ids: string[]; chat_id?: string }
|
| { type: 'messages_deleted'; message_ids: string[]; chat_id?: string }
|
||||||
| { type: 'session_renamed'; session_id: string; name: string; chat_id?: string }
|
|
||||||
| { type: 'chat_renamed'; chat_id: string; name: string }
|
| { type: 'chat_renamed'; chat_id: string; name: string }
|
||||||
| { type: 'error'; message_id?: string; chat_id?: string; error: string };
|
| { type: 'error'; message_id?: string; chat_id?: string; error: string };
|
||||||
|
|||||||
55
apps/web/src/components/ChatContextPopover.tsx
Normal file
55
apps/web/src/components/ChatContextPopover.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import type { ChatContextStats } from '@/hooks/useChatContextStats';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
stats: ChatContextStats | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a token count into a compact k/m-suffix string.
|
||||||
|
* - < 1_000 → raw integer (e.g. "42")
|
||||||
|
* - 1_000–999_999 → "Nk" or "N.Nk" (e.g. "30k", "12.5k", "100k")
|
||||||
|
* - >= 1_000_000 → "Nm" or "N.Nm" (e.g. "1m", "1.5m", "100m")
|
||||||
|
*
|
||||||
|
* Drops a trailing ".0" so we get "30k" instead of "30.0k".
|
||||||
|
*/
|
||||||
|
function formatTokens(n: number): string {
|
||||||
|
if (n < 1000) return String(n);
|
||||||
|
if (n < 1_000_000) {
|
||||||
|
const k = n / 1000;
|
||||||
|
return k >= 100 ? `${Math.round(k)}k` : `${k.toFixed(1).replace(/\.0$/, '')}k`;
|
||||||
|
}
|
||||||
|
const m = n / 1_000_000;
|
||||||
|
return m >= 100 ? `${Math.round(m)}m` : `${m.toFixed(1).replace(/\.0$/, '')}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Color thresholds:
|
||||||
|
* - > 85% → text-destructive
|
||||||
|
* - >= 60% → text-amber-500
|
||||||
|
* - else → text-muted-foreground
|
||||||
|
* (85% itself falls into the amber band.)
|
||||||
|
*/
|
||||||
|
function percentColorClass(percent: number): string {
|
||||||
|
if (percent > 85) return 'text-destructive';
|
||||||
|
if (percent >= 60) return 'text-amber-500';
|
||||||
|
return 'text-muted-foreground';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatContextPopover({ stats }: Props) {
|
||||||
|
if (!stats) return null;
|
||||||
|
return (
|
||||||
|
<div className="absolute bottom-full right-4 mb-4 z-20 pointer-events-none">
|
||||||
|
<div className="rounded-md border border-border bg-card text-card-foreground shadow-sm px-3 py-2 text-xs min-w-[140px]">
|
||||||
|
<div className="text-muted-foreground/80 text-[10px] uppercase tracking-wide mb-0.5">
|
||||||
|
Context window
|
||||||
|
</div>
|
||||||
|
<div className={`text-base font-medium ${percentColorClass(stats.percent)}`}>
|
||||||
|
{stats.percent}% used
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground text-[10px] font-mono">
|
||||||
|
{formatTokens(stats.used)} / {formatTokens(stats.max)} tokens
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import { AttachmentPreviewModal } from '@/components/AttachmentPreviewModal';
|
|||||||
import { FileMentionPopover } from '@/components/FileMentionPopover';
|
import { FileMentionPopover } from '@/components/FileMentionPopover';
|
||||||
import { api } from '@/api/client';
|
import { api } from '@/api/client';
|
||||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||||
|
import { useViewport } from '@/hooks/useViewport';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
@@ -18,6 +19,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ChatInput({ disabled, projectId, onSend, onForceSend }: Props) {
|
export function ChatInput({ disabled, projectId, onSend, onForceSend }: Props) {
|
||||||
|
const { isMobile } = useViewport();
|
||||||
const [value, setValue] = useState('');
|
const [value, setValue] = useState('');
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||||
@@ -185,6 +187,11 @@ export function ChatInput({ disabled, projectId, onSend, onForceSend }: Props) {
|
|||||||
|
|
||||||
function onKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
|
function onKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
|
||||||
if (mentionState?.open) return;
|
if (mentionState?.open) return;
|
||||||
|
// IME safety: never act on Enter while an IME composition is in flight
|
||||||
|
// (CJK input methods commit composition via Enter). Without this, the
|
||||||
|
// first Enter of a Japanese/Chinese/Korean composition would submit
|
||||||
|
// instead of finalizing the candidate.
|
||||||
|
if (e.nativeEvent.isComposing) return;
|
||||||
if (e.key === 'Enter' && e.shiftKey && (e.metaKey || e.ctrlKey) && onForceSend) {
|
if (e.key === 'Enter' && e.shiftKey && (e.metaKey || e.ctrlKey) && onForceSend) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
void forceSubmit();
|
void forceSubmit();
|
||||||
@@ -195,7 +202,9 @@ export function ChatInput({ disabled, projectId, onSend, onForceSend }: Props) {
|
|||||||
void submit();
|
void submit();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
// Bare Enter: sends on desktop, inserts a newline on mobile (per spec —
|
||||||
|
// send is via the dedicated button on touch devices).
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey && !isMobile) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
void submit();
|
void submit();
|
||||||
}
|
}
|
||||||
@@ -219,7 +228,7 @@ export function ChatInput({ disabled, projectId, onSend, onForceSend }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-t">
|
<div className="border-t" style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}>
|
||||||
<div className="max-w-[1000px] mx-auto w-full">
|
<div className="max-w-[1000px] mx-auto w-full">
|
||||||
{attachments.length > 0 && (
|
{attachments.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1.5 px-4 pt-3">
|
<div className="flex flex-wrap gap-1.5 px-4 pt-3">
|
||||||
@@ -239,7 +248,11 @@ export function ChatInput({ disabled, projectId, onSend, onForceSend }: Props) {
|
|||||||
value={value}
|
value={value}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
placeholder="Ask about this project. Enter to send, Shift+Enter for newline."
|
placeholder={
|
||||||
|
isMobile
|
||||||
|
? 'Ask about this project. Tap send to submit.'
|
||||||
|
: 'Ask about this project. Enter to send · Shift+Enter for newline.'
|
||||||
|
}
|
||||||
disabled={disabled || busy}
|
disabled={disabled || busy}
|
||||||
rows={3}
|
rows={3}
|
||||||
className="resize-none min-h-[68px] max-h-[240px]"
|
className="resize-none min-h-[68px] max-h-[240px]"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
ContextMenuSeparator,
|
ContextMenuSeparator,
|
||||||
ContextMenuTrigger,
|
ContextMenuTrigger,
|
||||||
} from '@/components/ui/context-menu';
|
} from '@/components/ui/context-menu';
|
||||||
|
import { useLongPress } from '@/hooks/useLongPress';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -40,6 +41,18 @@ export function ChatTabBar({
|
|||||||
const [renamingId, setRenamingId] = useState<string | null>(null);
|
const [renamingId, setRenamingId] = useState<string | null>(null);
|
||||||
const [renameValue, setRenameValue] = useState('');
|
const [renameValue, setRenameValue] = useState('');
|
||||||
|
|
||||||
|
// Long-press: dispatch a synthetic contextmenu event on the tab so the
|
||||||
|
// existing Radix ContextMenuTrigger opens at the touch coordinates. Works
|
||||||
|
// because asChild composition makes the tab div the trigger element.
|
||||||
|
const longPress = useLongPress(({ clientX, clientY, target }) => {
|
||||||
|
if (!target || !(target instanceof Element)) return;
|
||||||
|
const tab = target.closest('[data-tab-id]') as HTMLElement | null;
|
||||||
|
if (!tab) return;
|
||||||
|
tab.dispatchEvent(
|
||||||
|
new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX, clientY }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
function startRename(chatId: string, currentName: string | null) {
|
function startRename(chatId: string, currentName: string | null) {
|
||||||
setRenamingId(chatId);
|
setRenamingId(chatId);
|
||||||
setRenameValue(currentName ?? '');
|
setRenameValue(currentName ?? '');
|
||||||
@@ -63,7 +76,13 @@ export function ChatTabBar({
|
|||||||
<ContextMenu key={chat.id}>
|
<ContextMenu key={chat.id}>
|
||||||
<ContextMenuTrigger asChild>
|
<ContextMenuTrigger asChild>
|
||||||
<div
|
<div
|
||||||
|
data-tab-id={chat.id}
|
||||||
onClick={() => onSwitchTab(tabIdx)}
|
onClick={() => onSwitchTab(tabIdx)}
|
||||||
|
onTouchStart={longPress.onTouchStart}
|
||||||
|
onTouchMove={longPress.onTouchMove}
|
||||||
|
onTouchEnd={longPress.onTouchEnd}
|
||||||
|
onTouchCancel={longPress.onTouchCancel}
|
||||||
|
style={{ WebkitTouchCallout: 'none' }}
|
||||||
className={cn(
|
className={cn(
|
||||||
'group flex items-center gap-1.5 px-3 py-1.5 text-xs border-r border-border cursor-default select-none shrink-0',
|
'group flex items-center gap-1.5 px-3 py-1.5 text-xs border-r border-border cursor-default select-none shrink-0',
|
||||||
isActive
|
isActive
|
||||||
@@ -96,7 +115,7 @@ export function ChatTabBar({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onRemoveTab(chat.id);
|
onRemoveTab(chat.id);
|
||||||
}}
|
}}
|
||||||
className="p-0.5 hover:bg-muted rounded opacity-0 group-hover:opacity-60 hover:!opacity-100 shrink-0"
|
className="inline-flex items-center justify-center p-0.5 hover:bg-muted rounded opacity-0 group-hover:opacity-60 hover:!opacity-100 shrink-0 max-md:min-h-[44px] max-md:min-w-[44px] max-md:opacity-100"
|
||||||
aria-label="Close tab"
|
aria-label="Close tab"
|
||||||
>
|
>
|
||||||
<X size={10} />
|
<X size={10} />
|
||||||
@@ -104,6 +123,10 @@ export function ChatTabBar({
|
|||||||
</div>
|
</div>
|
||||||
</ContextMenuTrigger>
|
</ContextMenuTrigger>
|
||||||
<ContextMenuContent>
|
<ContextMenuContent>
|
||||||
|
<ContextMenuItem onSelect={() => onNewChat()}>
|
||||||
|
New chat
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuSeparator />
|
||||||
<ContextMenuItem onSelect={() => startRename(chat.id, chat.name)}>
|
<ContextMenuItem onSelect={() => startRename(chat.id, chat.name)}>
|
||||||
Rename
|
Rename
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
@@ -142,7 +165,7 @@ export function ChatTabBar({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onNewChat}
|
onClick={onNewChat}
|
||||||
className="p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground"
|
className="inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||||
aria-label="New chat"
|
aria-label="New chat"
|
||||||
title="New chat"
|
title="New chat"
|
||||||
>
|
>
|
||||||
@@ -152,7 +175,7 @@ export function ChatTabBar({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={onShowHistory}
|
onClick={onShowHistory}
|
||||||
className={cn(
|
className={cn(
|
||||||
'p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground',
|
'inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]',
|
||||||
pane.kind === 'empty' && 'text-foreground bg-muted/50'
|
pane.kind === 'empty' && 'text-foreground bg-muted/50'
|
||||||
)}
|
)}
|
||||||
aria-label="Session history"
|
aria-label="Session history"
|
||||||
@@ -164,7 +187,7 @@ export function ChatTabBar({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onRemovePane}
|
onClick={onRemovePane}
|
||||||
className="p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground"
|
className="inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||||
aria-label="Close pane"
|
aria-label="Close pane"
|
||||||
title="Close pane"
|
title="Close pane"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export function CreateProjectModal({ open, onOpenChange }: Props) {
|
|||||||
/>
|
/>
|
||||||
{name && (
|
{name && (
|
||||||
<div className="text-xs text-muted-foreground font-mono">
|
<div className="text-xs text-muted-foreground font-mono">
|
||||||
Folder: /opt/{folderPreview || <span className="text-destructive">(empty after sanitization)</span>}
|
Folder: /opt/projects/{folderPreview || <span className="text-destructive">(empty after sanitization)</span>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -266,11 +266,11 @@ function ActionRow({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity max-md:opacity-100">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void copy()}
|
onClick={() => void copy()}
|
||||||
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground"
|
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||||
aria-label="Copy message"
|
aria-label="Copy message"
|
||||||
title="Copy"
|
title="Copy"
|
||||||
>
|
>
|
||||||
@@ -281,7 +281,7 @@ function ActionRow({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => void regenerate()}
|
onClick={() => void regenerate()}
|
||||||
disabled={!canRegen || regenerating}
|
disabled={!canRegen || regenerating}
|
||||||
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed"
|
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||||
aria-label="Regenerate message"
|
aria-label="Regenerate message"
|
||||||
title="Regenerate"
|
title="Regenerate"
|
||||||
>
|
>
|
||||||
@@ -292,7 +292,7 @@ function ActionRow({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => void fork()}
|
onClick={() => void fork()}
|
||||||
disabled={!canFork || forking}
|
disabled={!canFork || forking}
|
||||||
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed"
|
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||||
aria-label="Fork from here"
|
aria-label="Fork from here"
|
||||||
title="Fork from here"
|
title="Fork from here"
|
||||||
>
|
>
|
||||||
@@ -302,7 +302,7 @@ function ActionRow({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setDeleteOpen(true)}
|
onClick={() => setDeleteOpen(true)}
|
||||||
disabled={!canDelete}
|
disabled={!canDelete}
|
||||||
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-destructive disabled:opacity-40 disabled:cursor-not-allowed"
|
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-destructive disabled:opacity-40 disabled:cursor-not-allowed max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||||
aria-label="Delete message"
|
aria-label="Delete message"
|
||||||
title="Delete message"
|
title="Delete message"
|
||||||
>
|
>
|
||||||
@@ -476,7 +476,7 @@ export function MessageBubble({ message, sessionChats }: Props) {
|
|||||||
if (message.role === 'user') {
|
if (message.role === 'user') {
|
||||||
return (
|
return (
|
||||||
<div className="group flex flex-col items-end gap-1">
|
<div className="group flex flex-col items-end gap-1">
|
||||||
<div className="max-w-[80%] rounded-lg bg-primary text-primary-foreground px-3 py-2 text-sm whitespace-pre-wrap">
|
<div className="max-w-[80%] rounded-lg bg-primary text-primary-foreground px-3 py-2 text-sm whitespace-pre-wrap break-words min-w-0">
|
||||||
{message.content}
|
{message.content}
|
||||||
</div>
|
</div>
|
||||||
<ActionRow message={message} />
|
<ActionRow message={message} />
|
||||||
@@ -495,7 +495,7 @@ export function MessageBubble({ message, sessionChats }: Props) {
|
|||||||
<ToolCallCard key={tc.id} toolCall={tc} />
|
<ToolCallCard key={tc.id} toolCall={tc} />
|
||||||
))}
|
))}
|
||||||
{(hasContent || (!hasToolCalls && isStreaming)) && (
|
{(hasContent || (!hasToolCalls && isStreaming)) && (
|
||||||
<div className="max-w-[90%] text-sm leading-relaxed space-y-2">
|
<div className="max-w-[90%] text-sm leading-relaxed space-y-2 break-words min-w-0">
|
||||||
{hasContent ? <MarkdownBody content={message.content} /> : null}
|
{hasContent ? <MarkdownBody content={message.content} /> : null}
|
||||||
{isStreaming && (
|
{isStreaming && (
|
||||||
<span className="inline-block w-1.5 h-3.5 align-baseline bg-muted-foreground/60 animate-pulse" />
|
<span className="inline-block w-1.5 h-3.5 align-baseline bg-muted-foreground/60 animate-pulse" />
|
||||||
|
|||||||
@@ -19,8 +19,10 @@ import {
|
|||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { AddProjectModal } from './AddProjectModal';
|
import { AddProjectModal } from './AddProjectModal';
|
||||||
import { api } from '@/api/client';
|
import { api } from '@/api/client';
|
||||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
|
||||||
import { useSidebar } from '@/hooks/useSidebar';
|
import { useSidebar } from '@/hooks/useSidebar';
|
||||||
|
import { useSidebarDrawer } from '@/hooks/useSidebarDrawer';
|
||||||
|
import { useViewport } from '@/hooks/useViewport';
|
||||||
|
import { usePullToRefresh } from '@/hooks/usePullToRefresh';
|
||||||
import type { SidebarProject } from '@/api/types';
|
import type { SidebarProject } from '@/api/types';
|
||||||
import { giteaUrlFor } from '@/lib/projectUrls';
|
import { giteaUrlFor } from '@/lib/projectUrls';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -186,7 +188,8 @@ export function ProjectSidebar() {
|
|||||||
if (!trimmed) return;
|
if (!trimmed) return;
|
||||||
try {
|
try {
|
||||||
await api.sessions.update(sessionId, { name: trimmed });
|
await api.sessions.update(sessionId, { name: trimmed });
|
||||||
sessionEvents.emit({ type: 'session_renamed', session_id: sessionId, name: trimmed });
|
// Server publishes session_renamed via broker.publishUser; useUserEvents
|
||||||
|
// forwards onto the bus. No local emit needed.
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : 'failed to rename session');
|
toast.error(err instanceof Error ? err.message : 'failed to rename session');
|
||||||
}
|
}
|
||||||
@@ -195,8 +198,24 @@ export function ProjectSidebar() {
|
|||||||
const rowCls = (active: boolean) =>
|
const rowCls = (active: boolean) =>
|
||||||
active ? 'bg-sidebar-accent text-sidebar-accent-foreground' : 'hover:bg-sidebar-accent/60';
|
active ? 'bg-sidebar-accent text-sidebar-accent-foreground' : 'hover:bg-sidebar-accent/60';
|
||||||
|
|
||||||
|
const { open: drawerOpen } = useSidebarDrawer();
|
||||||
|
const { isMobile } = useViewport();
|
||||||
|
const pull = usePullToRefresh(() => retry(), { enabled: isMobile });
|
||||||
|
|
||||||
|
// On mobile the sidebar is a slide-in drawer (fixed, z-40, off-screen by
|
||||||
|
// default). On desktop it sits inline as a normal flex column. The
|
||||||
|
// backdrop is rendered by AppShell; drawer-open state lives in
|
||||||
|
// SidebarDrawerProvider.
|
||||||
|
const asideCls = isMobile
|
||||||
|
? cn(
|
||||||
|
'fixed inset-y-0 left-0 z-40 w-60 border-r bg-sidebar text-sidebar-foreground flex flex-col',
|
||||||
|
'transition-transform duration-200 ease-out',
|
||||||
|
drawerOpen ? 'translate-x-0' : '-translate-x-full',
|
||||||
|
)
|
||||||
|
: 'w-60 shrink-0 border-r bg-sidebar text-sidebar-foreground flex flex-col h-screen';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="w-60 shrink-0 border-r bg-sidebar text-sidebar-foreground flex flex-col h-screen">
|
<aside className={asideCls}>
|
||||||
<div className="px-4 py-3 border-b flex items-center justify-between">
|
<div className="px-4 py-3 border-b flex items-center justify-between">
|
||||||
<NavLink to="/" className="font-semibold tracking-tight text-base">
|
<NavLink to="/" className="font-semibold tracking-tight text-base">
|
||||||
BooCode
|
BooCode
|
||||||
@@ -206,7 +225,30 @@ export function ProjectSidebar() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="flex-1 overflow-y-auto py-2">
|
{isMobile && (pull.pullDist > 0 || pull.refreshing) && (
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-center text-[10px] uppercase tracking-wide text-muted-foreground border-b overflow-hidden shrink-0"
|
||||||
|
style={{
|
||||||
|
height: pull.refreshing ? 32 : Math.min(pull.pullDist, 80),
|
||||||
|
transition: pull.pullDist === 0 && !pull.refreshing ? 'height 0.2s ease' : undefined,
|
||||||
|
}}
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
{pull.refreshing
|
||||||
|
? 'Refreshing…'
|
||||||
|
: pull.pullDist >= 80
|
||||||
|
? 'Release to refresh'
|
||||||
|
: 'Pull to refresh'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<nav
|
||||||
|
className="flex-1 overflow-y-auto py-2"
|
||||||
|
onTouchStart={isMobile ? pull.onTouchStart : undefined}
|
||||||
|
onTouchMove={isMobile ? pull.onTouchMove : undefined}
|
||||||
|
onTouchEnd={isMobile ? pull.onTouchEnd : undefined}
|
||||||
|
onTouchCancel={isMobile ? pull.onTouchEnd : undefined}
|
||||||
|
>
|
||||||
{loading && data == null && (
|
{loading && data == null && (
|
||||||
<div className="space-y-2 px-2">
|
<div className="space-y-2 px-2">
|
||||||
{[0, 1, 2, 3].map((i) => (
|
{[0, 1, 2, 3].map((i) => (
|
||||||
|
|||||||
@@ -4,8 +4,11 @@ import { api } from '@/api/client';
|
|||||||
import type { FileEntry } from '@/api/types';
|
import type { FileEntry } from '@/api/types';
|
||||||
import { inferLanguage } from '@/lib/attachments';
|
import { inferLanguage } from '@/lib/attachments';
|
||||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||||
|
import { useRightRailDrawer } from '@/hooks/useRightRailDrawer';
|
||||||
|
import { useViewport } from '@/hooks/useViewport';
|
||||||
import { FileViewerOverlay } from '@/components/FileViewerOverlay';
|
import { FileViewerOverlay } from '@/components/FileViewerOverlay';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@@ -25,6 +28,8 @@ function joinPath(parent: string, name: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function RightRail({ projectId }: Props) {
|
export function RightRail({ projectId }: Props) {
|
||||||
|
const { isMobile } = useViewport();
|
||||||
|
const { open: drawerOpen, setOpen: setDrawerOpen } = useRightRailDrawer();
|
||||||
const [open, setOpen] = useState(() => {
|
const [open, setOpen] = useState(() => {
|
||||||
try { return localStorage.getItem(`${STORAGE_KEY}.open`) !== 'false'; } catch { return true; }
|
try { return localStorage.getItem(`${STORAGE_KEY}.open`) !== 'false'; } catch { return true; }
|
||||||
});
|
});
|
||||||
@@ -34,7 +39,21 @@ export function RightRail({ projectId }: Props) {
|
|||||||
const [fullFileList, setFullFileList] = useState<string[] | null>(null);
|
const [fullFileList, setFullFileList] = useState<string[] | null>(null);
|
||||||
const [viewerFile, setViewerFile] = useState<{ path: string; content: string } | null>(null);
|
const [viewerFile, setViewerFile] = useState<{ path: string; content: string } | null>(null);
|
||||||
|
|
||||||
|
// Combined open state: on mobile use the global drawer state (toggled by
|
||||||
|
// the Session header's FolderTree button); on desktop use the persistent
|
||||||
|
// internal state.
|
||||||
|
const isOpen = isMobile ? drawerOpen : open;
|
||||||
|
const closeRail = useCallback(() => {
|
||||||
|
if (isMobile) setDrawerOpen(false);
|
||||||
|
else setOpen(false);
|
||||||
|
}, [isMobile, setDrawerOpen]);
|
||||||
|
const openRail = useCallback(() => {
|
||||||
|
if (isMobile) setDrawerOpen(true);
|
||||||
|
else setOpen(true);
|
||||||
|
}, [isMobile, setDrawerOpen]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// best-effort; ignore failure because localStorage may be unavailable (quota, private mode)
|
||||||
try { localStorage.setItem(`${STORAGE_KEY}.open`, String(open)); } catch {}
|
try { localStorage.setItem(`${STORAGE_KEY}.open`, String(open)); } catch {}
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
@@ -55,9 +74,9 @@ export function RightRail({ projectId }: Props) {
|
|||||||
}, [projectId]);
|
}, [projectId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!isOpen) return;
|
||||||
if (!cache.has('')) void loadDir('');
|
if (!cache.has('')) void loadDir('');
|
||||||
}, [open, cache, loadDir]);
|
}, [isOpen, cache, loadDir]);
|
||||||
|
|
||||||
function toggleDir(dirPath: string) {
|
function toggleDir(dirPath: string) {
|
||||||
setExpandedDirs((prev) => {
|
setExpandedDirs((prev) => {
|
||||||
@@ -107,12 +126,14 @@ export function RightRail({ projectId }: Props) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return sessionEvents.subscribe((event) => {
|
return sessionEvents.subscribe((event) => {
|
||||||
if (event.type !== 'open_file_in_browser') return;
|
if (event.type !== 'open_file_in_browser') return;
|
||||||
if (!open) setOpen(true);
|
if (!isOpen) openRail();
|
||||||
void openFile(event.path);
|
void openFile(event.path);
|
||||||
});
|
});
|
||||||
}, [open, projectId]);
|
}, [isOpen, openRail, projectId]);
|
||||||
|
|
||||||
if (!open) {
|
// Desktop closed state: render the floating chevron handle. Mobile never
|
||||||
|
// shows the handle — the toggle lives in the Session header on mobile.
|
||||||
|
if (!isMobile && !open) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -127,15 +148,25 @@ export function RightRail({ projectId }: Props) {
|
|||||||
|
|
||||||
const rootEntries = cache.get('') ?? [];
|
const rootEntries = cache.get('') ?? [];
|
||||||
|
|
||||||
|
// Mobile: render as fixed-position right-side drawer (always mounted so
|
||||||
|
// the transform transition can animate in/out). Desktop: inline aside.
|
||||||
|
const asideCls = isMobile
|
||||||
|
? cn(
|
||||||
|
'fixed inset-y-0 right-0 z-40 w-[85vw] max-w-sm border-l bg-sidebar flex flex-col overflow-hidden',
|
||||||
|
'transition-transform duration-200 ease-out',
|
||||||
|
drawerOpen ? 'translate-x-0' : 'translate-x-full',
|
||||||
|
)
|
||||||
|
: 'w-64 shrink-0 border-l bg-sidebar flex flex-col h-full overflow-hidden';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<aside className="w-64 shrink-0 border-l bg-sidebar flex flex-col h-full overflow-hidden">
|
<aside className={asideCls}>
|
||||||
<div className="flex items-center gap-2 px-3 py-2 border-b shrink-0">
|
<div className="flex items-center gap-2 px-3 py-2 border-b shrink-0">
|
||||||
<span className="text-xs font-medium flex-1">Files</span>
|
<span className="text-xs font-medium flex-1">Files</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpen(false)}
|
onClick={closeRail}
|
||||||
className="p-1 rounded hover:bg-muted text-muted-foreground"
|
className="p-1 rounded hover:bg-muted text-muted-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||||
aria-label="Close file browser"
|
aria-label="Close file browser"
|
||||||
>
|
>
|
||||||
<PanelRightClose size={14} />
|
<PanelRightClose size={14} />
|
||||||
|
|||||||
103
apps/web/src/components/SwipeablePaneTab.tsx
Normal file
103
apps/web/src/components/SwipeablePaneTab.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import type { TouchEvent } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
label: string;
|
||||||
|
isActive: boolean;
|
||||||
|
onTap: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
canClose: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CLOSE_THRESHOLD = 60;
|
||||||
|
const MAX_TRAVEL = 120;
|
||||||
|
const VERTICAL_BAIL = 30;
|
||||||
|
|
||||||
|
// Pane tab with horizontal swipe-to-close (mobile only). Tracks horizontal
|
||||||
|
// finger movement; if vertical exceeds VERTICAL_BAIL the gesture is cancelled
|
||||||
|
// (so vertical scroll still works). On release past CLOSE_THRESHOLD, the
|
||||||
|
// onClose callback fires. Otherwise the tab snaps back. Hand-rolled per spec.
|
||||||
|
export function SwipeablePaneTab({ label, isActive, onTap, onClose, canClose }: Props) {
|
||||||
|
const [translateX, setTranslateX] = useState(0);
|
||||||
|
const [dragging, setDragging] = useState(false);
|
||||||
|
const startRef = useRef<{ x: number; y: number; bailed: boolean } | null>(null);
|
||||||
|
|
||||||
|
const onTouchStart = (e: TouchEvent) => {
|
||||||
|
if (!canClose) return;
|
||||||
|
const t = e.touches[0];
|
||||||
|
if (!t) return;
|
||||||
|
startRef.current = { x: t.clientX, y: t.clientY, bailed: false };
|
||||||
|
setDragging(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onTouchMove = (e: TouchEvent) => {
|
||||||
|
const start = startRef.current;
|
||||||
|
if (!start || start.bailed) return;
|
||||||
|
const t = e.touches[0];
|
||||||
|
if (!t) return;
|
||||||
|
const dx = t.clientX - start.x;
|
||||||
|
const dy = t.clientY - start.y;
|
||||||
|
if (Math.abs(dy) > VERTICAL_BAIL) {
|
||||||
|
start.bailed = true;
|
||||||
|
setTranslateX(0);
|
||||||
|
setDragging(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (dx < 0) {
|
||||||
|
setTranslateX(Math.max(dx, -MAX_TRAVEL));
|
||||||
|
} else {
|
||||||
|
setTranslateX(0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onTouchEnd = () => {
|
||||||
|
const start = startRef.current;
|
||||||
|
startRef.current = null;
|
||||||
|
setDragging(false);
|
||||||
|
if (!start || start.bailed) {
|
||||||
|
setTranslateX(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tx = translateX;
|
||||||
|
if (tx <= -CLOSE_THRESHOLD) {
|
||||||
|
onClose();
|
||||||
|
// Don't reset translateX; the parent will unmount this tab.
|
||||||
|
} else {
|
||||||
|
setTranslateX(0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Opacity fades from 1 -> 0.4 as the tab approaches the close threshold.
|
||||||
|
const opacity =
|
||||||
|
translateX < 0
|
||||||
|
? Math.max(0.4, 1 - (Math.abs(translateX) / CLOSE_THRESHOLD) * 0.6)
|
||||||
|
: 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onTap}
|
||||||
|
onTouchStart={onTouchStart}
|
||||||
|
onTouchMove={onTouchMove}
|
||||||
|
onTouchEnd={onTouchEnd}
|
||||||
|
onTouchCancel={onTouchEnd}
|
||||||
|
style={{
|
||||||
|
transform: `translateX(${translateX}px)`,
|
||||||
|
opacity,
|
||||||
|
// Only animate when releasing (snap-back); during drag the transform
|
||||||
|
// tracks the finger 1:1 for a tight feel.
|
||||||
|
transition: dragging ? undefined : 'transform 0.15s ease, opacity 0.15s ease',
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'shrink-0 px-3 py-2 text-xs rounded min-h-[44px] min-w-[44px]',
|
||||||
|
isActive
|
||||||
|
? 'bg-background text-foreground border'
|
||||||
|
: 'text-muted-foreground hover:bg-muted/40',
|
||||||
|
)}
|
||||||
|
aria-current={isActive ? 'true' : undefined}
|
||||||
|
>
|
||||||
|
<span className="truncate max-w-[140px] inline-block">{label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback, useEffect } from 'react';
|
||||||
|
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { PanelRight, MessageSquare, Terminal, Bot } from 'lucide-react';
|
import { PanelRight, MessageSquare, Terminal, Bot } from 'lucide-react';
|
||||||
import type { Chat, WorkspacePane } from '@/api/types';
|
import type { Chat, WorkspacePane } from '@/api/types';
|
||||||
import { useWorkspacePanes, MAX_PANES } from '@/hooks/useWorkspacePanes';
|
import { useWorkspacePanes, MAX_PANES } from '@/hooks/useWorkspacePanes';
|
||||||
import { useSessionChats } from '@/hooks/useSessionChats';
|
import { useSessionChats } from '@/hooks/useSessionChats';
|
||||||
|
import { useViewport } from '@/hooks/useViewport';
|
||||||
import { ChatPane } from '@/components/panes/ChatPane';
|
import { ChatPane } from '@/components/panes/ChatPane';
|
||||||
import { ChatTabBar } from '@/components/ChatTabBar';
|
import { ChatTabBar } from '@/components/ChatTabBar';
|
||||||
import { SessionLandingPage } from '@/components/SessionLandingPage';
|
import { SessionLandingPage } from '@/components/SessionLandingPage';
|
||||||
|
import { SwipeablePaneTab } from '@/components/SwipeablePaneTab';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -67,67 +70,137 @@ export function Workspace({ sessionId, projectId }: Props) {
|
|||||||
initializeFirstChatIfEmpty,
|
initializeFirstChatIfEmpty,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { isMobile } = useViewport();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
// URL -> state (mobile only). Handles deep-link arrival and Back button
|
||||||
|
// history pops. On a bare URL (no ?pane), reset to first pane so Back
|
||||||
|
// from a ?pane URL returns the user to a sensible default.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isMobile || panes.length === 0) return;
|
||||||
|
const paneId = searchParams.get('pane');
|
||||||
|
if (!paneId) {
|
||||||
|
if (activePaneIdx !== 0) setActivePaneIdx(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const idx = panes.findIndex((p) => p.id === paneId);
|
||||||
|
if (idx >= 0 && idx !== activePaneIdx) setActivePaneIdx(idx);
|
||||||
|
}, [isMobile, searchParams, panes, activePaneIdx, setActivePaneIdx]);
|
||||||
|
|
||||||
|
// Switch active pane and push URL (mobile only). User-initiated only;
|
||||||
|
// never called from URL-sync effect.
|
||||||
|
const switchActivePane = useCallback(
|
||||||
|
(idx: number) => {
|
||||||
|
setActivePaneIdx(idx);
|
||||||
|
if (isMobile) {
|
||||||
|
const pane = panes[idx];
|
||||||
|
if (!pane) return;
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
params.set('pane', pane.id);
|
||||||
|
navigate(`${location.pathname}?${params.toString()}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setActivePaneIdx, isMobile, panes, navigate, location.pathname, location.search],
|
||||||
|
);
|
||||||
|
|
||||||
function chatsForPane(pane: WorkspacePane): Chat[] {
|
function chatsForPane(pane: WorkspacePane): Chat[] {
|
||||||
return pane.chatIds
|
return pane.chatIds
|
||||||
.map((id) => chats.find((c) => c.id === id))
|
.map((id) => chats.find((c) => c.id === id))
|
||||||
.filter((c): c is Chat => c !== undefined);
|
.filter((c): c is Chat => c !== undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function paneLabel(pane: WorkspacePane): string {
|
||||||
|
const activeChatId = pane.chatId;
|
||||||
|
if (activeChatId) {
|
||||||
|
const chat = chats.find((c) => c.id === activeChatId);
|
||||||
|
if (chat) return chat.name ?? 'New chat';
|
||||||
|
}
|
||||||
|
if (pane.kind === 'chat') return 'Chat';
|
||||||
|
if (pane.kind === 'terminal') return 'Terminal';
|
||||||
|
if (pane.kind === 'agent') return 'Agent';
|
||||||
|
return 'Empty';
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full min-h-0">
|
<div className="flex flex-col h-full min-h-0">
|
||||||
<div className="flex items-center gap-2 border-b border-border bg-muted/20 px-3 py-1.5 shrink-0">
|
{!isMobile && (
|
||||||
<DropdownMenu>
|
<div className="flex items-center gap-2 border-b border-border bg-muted/20 px-3 py-1.5 shrink-0">
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenu>
|
||||||
<button
|
<DropdownMenuTrigger asChild>
|
||||||
type="button"
|
<button
|
||||||
disabled={panes.length >= MAX_PANES}
|
type="button"
|
||||||
className={cn(
|
disabled={panes.length >= MAX_PANES}
|
||||||
'flex items-center gap-1 text-xs px-2 py-1 rounded hover:bg-muted',
|
className={cn(
|
||||||
panes.length >= MAX_PANES && 'opacity-40 cursor-not-allowed hover:bg-transparent'
|
'flex items-center gap-1 text-xs px-2 py-1 rounded hover:bg-muted',
|
||||||
)}
|
panes.length >= MAX_PANES && 'opacity-40 cursor-not-allowed hover:bg-transparent'
|
||||||
>
|
)}
|
||||||
<PanelRight size={14} />
|
>
|
||||||
Split
|
<PanelRight size={14} />
|
||||||
</button>
|
Split
|
||||||
</DropdownMenuTrigger>
|
</button>
|
||||||
<DropdownMenuContent>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuItem onSelect={() => addSplitPane('chat')}>
|
<DropdownMenuContent>
|
||||||
<MessageSquare size={14} /> Chat
|
<DropdownMenuItem onSelect={() => addSplitPane('chat')}>
|
||||||
</DropdownMenuItem>
|
<MessageSquare size={14} /> Chat
|
||||||
<DropdownMenuItem onSelect={() => addSplitPane('terminal')}>
|
</DropdownMenuItem>
|
||||||
<Terminal size={14} /> Terminal
|
<DropdownMenuItem onSelect={() => addSplitPane('terminal')}>
|
||||||
</DropdownMenuItem>
|
<Terminal size={14} /> Terminal
|
||||||
<DropdownMenuItem onSelect={() => addSplitPane('agent')}>
|
</DropdownMenuItem>
|
||||||
<Bot size={14} /> Agent
|
<DropdownMenuItem onSelect={() => addSplitPane('agent')}>
|
||||||
</DropdownMenuItem>
|
<Bot size={14} /> Agent
|
||||||
</DropdownMenuContent>
|
</DropdownMenuItem>
|
||||||
</DropdownMenu>
|
</DropdownMenuContent>
|
||||||
</div>
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isMobile && panes.length > 1 && (
|
||||||
|
<div className="flex items-center gap-1 overflow-x-auto border-b border-border bg-muted/10 px-2 py-1 shrink-0">
|
||||||
|
{panes.map((pane, idx) => (
|
||||||
|
<SwipeablePaneTab
|
||||||
|
key={pane.id}
|
||||||
|
label={paneLabel(pane)}
|
||||||
|
isActive={idx === activePaneIdx}
|
||||||
|
onTap={() => switchActivePane(idx)}
|
||||||
|
onClose={() => removePane(idx)}
|
||||||
|
canClose={panes.length > 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="flex-1 grid min-h-0"
|
className={cn('flex-1 min-h-0', isMobile ? 'flex' : 'grid')}
|
||||||
style={{
|
style={
|
||||||
gridTemplateColumns: `repeat(${panes.length}, minmax(0, 1fr))`,
|
isMobile
|
||||||
}}
|
? undefined
|
||||||
|
: { gridTemplateColumns: `repeat(${panes.length}, minmax(0, 1fr))` }
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{panes.map((pane, idx) => (
|
{panes.map((pane, idx) => {
|
||||||
|
const visible = !isMobile || idx === activePaneIdx;
|
||||||
|
if (!visible) return null;
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={pane.id}
|
key={pane.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex flex-col h-full min-h-0 border-r border-border last:border-r-0 relative',
|
'flex flex-col h-full min-h-0 border-r border-border last:border-r-0 relative',
|
||||||
idx === activePaneIdx && 'ring-1 ring-inset ring-ring/20',
|
isMobile ? 'flex-1 w-full' : undefined,
|
||||||
dragOverIdx === idx && draggingIdxRef.current !== idx &&
|
!isMobile && idx === activePaneIdx && 'ring-1 ring-inset ring-ring/20',
|
||||||
|
!isMobile && dragOverIdx === idx && draggingIdxRef.current !== idx &&
|
||||||
'before:absolute before:inset-y-0 before:left-0 before:w-0.5 before:bg-primary before:z-10'
|
'before:absolute before:inset-y-0 before:left-0 before:w-0.5 before:bg-primary before:z-10'
|
||||||
)}
|
)}
|
||||||
onClick={() => setActivePaneIdx(idx)}
|
onClick={() => setActivePaneIdx(idx)}
|
||||||
onDragOver={panes.length > 1 ? handlePaneDragOver(idx) : undefined}
|
onDragOver={!isMobile && panes.length > 1 ? handlePaneDragOver(idx) : undefined}
|
||||||
onDragLeave={panes.length > 1 ? handlePaneDragLeave : undefined}
|
onDragLeave={!isMobile && panes.length > 1 ? handlePaneDragLeave : undefined}
|
||||||
onDrop={panes.length > 1 ? handlePaneDrop(idx) : undefined}
|
onDrop={!isMobile && panes.length > 1 ? handlePaneDrop(idx) : undefined}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
draggable={panes.length > 1}
|
draggable={!isMobile && panes.length > 1}
|
||||||
onDragStart={panes.length > 1 ? handlePaneDragStart(idx) : undefined}
|
onDragStart={!isMobile && panes.length > 1 ? handlePaneDragStart(idx) : undefined}
|
||||||
onDragEnd={panes.length > 1 ? handlePaneDragEnd : undefined}
|
onDragEnd={!isMobile && panes.length > 1 ? handlePaneDragEnd : undefined}
|
||||||
>
|
>
|
||||||
<ChatTabBar
|
<ChatTabBar
|
||||||
pane={pane}
|
pane={pane}
|
||||||
@@ -165,7 +238,8 @@ export function Workspace({ sessionId, projectId }: Props) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ import { ChevronDown, Square, X } from 'lucide-react';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '@/api/client';
|
import { api } from '@/api/client';
|
||||||
import { useSessionStream } from '@/hooks/useSessionStream';
|
import { useSessionStream } from '@/hooks/useSessionStream';
|
||||||
|
import { useChatContextStats } from '@/hooks/useChatContextStats';
|
||||||
import { MessageList } from '@/components/MessageList';
|
import { MessageList } from '@/components/MessageList';
|
||||||
import { ChatInput } from '@/components/ChatInput';
|
import { ChatInput } from '@/components/ChatInput';
|
||||||
|
import { ChatContextPopover } from '@/components/ChatContextPopover';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -37,6 +39,7 @@ export function ChatPane({ sessionId, chatId, projectId, sessionChats }: Props)
|
|||||||
|
|
||||||
const chatMessages = stream.messages.filter((m) => m.chat_id === chatId);
|
const chatMessages = stream.messages.filter((m) => m.chat_id === chatId);
|
||||||
const streaming = chatMessages.some((m) => m.status === 'streaming');
|
const streaming = chatMessages.some((m) => m.status === 'streaming');
|
||||||
|
const contextStats = useChatContextStats(chatId, chatMessages);
|
||||||
|
|
||||||
// Auto-send next queued message when streaming completes
|
// Auto-send next queued message when streaming completes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -117,7 +120,7 @@ export function ChatPane({ sessionId, chatId, projectId, sessionChats }: Props)
|
|||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="p-0.5 hover:bg-muted rounded shrink-0"
|
className="inline-flex items-center justify-center p-0.5 hover:bg-muted rounded shrink-0 max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||||
aria-label="Queued message options"
|
aria-label="Queued message options"
|
||||||
>
|
>
|
||||||
<ChevronDown size={12} />
|
<ChevronDown size={12} />
|
||||||
@@ -135,7 +138,7 @@ export function ChatPane({ sessionId, chatId, projectId, sessionChats }: Props)
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => removeQueued(i)}
|
onClick={() => removeQueued(i)}
|
||||||
className="p-0.5 hover:bg-muted rounded shrink-0"
|
className="inline-flex items-center justify-center p-0.5 hover:bg-muted rounded shrink-0 max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||||
aria-label="Cancel queued message"
|
aria-label="Cancel queued message"
|
||||||
>
|
>
|
||||||
<X size={12} />
|
<X size={12} />
|
||||||
@@ -153,7 +156,7 @@ export function ChatPane({ sessionId, chatId, projectId, sessionChats }: Props)
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void handleStop()}
|
onClick={() => void handleStop()}
|
||||||
className="flex items-center gap-1.5 text-xs px-3 py-1 rounded-full border hover:bg-muted text-muted-foreground hover:text-foreground"
|
className="flex items-center gap-1.5 text-xs px-3 py-1 rounded-full border hover:bg-muted text-muted-foreground hover:text-foreground max-md:min-h-[44px] max-md:px-5"
|
||||||
>
|
>
|
||||||
<Square size={10} className="fill-current" />
|
<Square size={10} className="fill-current" />
|
||||||
Stop generating
|
Stop generating
|
||||||
@@ -162,7 +165,10 @@ export function ChatPane({ sessionId, chatId, projectId, sessionChats }: Props)
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ChatInput disabled={false} projectId={projectId} onSend={handleSend} onForceSend={streaming ? handleForceSend : undefined} />
|
<div className="relative">
|
||||||
|
<ChatContextPopover stats={contextStats} />
|
||||||
|
<ChatInput disabled={false} projectId={projectId} onSend={handleSend} onForceSend={streaming ? handleForceSend : undefined} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
37
apps/web/src/hooks/useChatContextStats.ts
Normal file
37
apps/web/src/hooks/useChatContextStats.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import type { Message } from '@/api/types';
|
||||||
|
|
||||||
|
export interface ChatContextStats {
|
||||||
|
used: number;
|
||||||
|
max: number;
|
||||||
|
percent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the latest context-window usage for the given chat, derived from the
|
||||||
|
* assistant message (with both ctx_used and ctx_max populated) having the most
|
||||||
|
* recent created_at. Returns null when no such message exists.
|
||||||
|
*
|
||||||
|
* Re-evaluates whenever the `messages` reference or `chatId` changes, which
|
||||||
|
* matches the cadence of streaming updates from `useSessionStream`.
|
||||||
|
*/
|
||||||
|
export function useChatContextStats(
|
||||||
|
chatId: string,
|
||||||
|
messages: Message[],
|
||||||
|
): ChatContextStats | null {
|
||||||
|
return useMemo(() => {
|
||||||
|
let latest: Message | null = null;
|
||||||
|
for (const m of messages) {
|
||||||
|
if (m.chat_id !== chatId) continue;
|
||||||
|
if (m.role !== 'assistant') continue;
|
||||||
|
if (m.ctx_used == null || m.ctx_max == null) continue;
|
||||||
|
if (!latest || m.created_at > latest.created_at) latest = m;
|
||||||
|
}
|
||||||
|
if (!latest || latest.ctx_used == null || latest.ctx_max == null) return null;
|
||||||
|
const used = latest.ctx_used;
|
||||||
|
const max = latest.ctx_max;
|
||||||
|
if (max <= 0) return null;
|
||||||
|
const percent = Math.round((used / max) * 100);
|
||||||
|
return { used, max, percent };
|
||||||
|
}, [chatId, messages]);
|
||||||
|
}
|
||||||
75
apps/web/src/hooks/useLongPress.ts
Normal file
75
apps/web/src/hooks/useLongPress.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { useCallback, useRef } from 'react';
|
||||||
|
import type { TouchEvent } from 'react';
|
||||||
|
|
||||||
|
interface LongPressHandlers {
|
||||||
|
onTouchStart: (e: TouchEvent) => void;
|
||||||
|
onTouchMove: (e: TouchEvent) => void;
|
||||||
|
onTouchEnd: (e: TouchEvent) => void;
|
||||||
|
onTouchCancel: (e: TouchEvent) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Options {
|
||||||
|
ms?: number;
|
||||||
|
// Suppress the synthetic click that follows touchend when long-press fired.
|
||||||
|
suppressClickOnFire?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hand-rolled long-press detector. Starts a timer on touchstart; cancels on
|
||||||
|
// touchmove or early touchend; fires the callback on timer expiry. Caller is
|
||||||
|
// expected to suppress text-selection callout via CSS (-webkit-touch-callout).
|
||||||
|
export function useLongPress(
|
||||||
|
callback: (touch: { clientX: number; clientY: number; target: EventTarget | null }) => void,
|
||||||
|
{ ms = 500, suppressClickOnFire = true }: Options = {},
|
||||||
|
): LongPressHandlers {
|
||||||
|
const timerRef = useRef<number | null>(null);
|
||||||
|
const firedRef = useRef(false);
|
||||||
|
|
||||||
|
const clear = useCallback(() => {
|
||||||
|
if (timerRef.current !== null) {
|
||||||
|
window.clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onTouchStart = useCallback(
|
||||||
|
(e: TouchEvent) => {
|
||||||
|
firedRef.current = false;
|
||||||
|
const touch = e.touches[0];
|
||||||
|
if (!touch) return;
|
||||||
|
const x = touch.clientX;
|
||||||
|
const y = touch.clientY;
|
||||||
|
const target = e.target;
|
||||||
|
clear();
|
||||||
|
timerRef.current = window.setTimeout(() => {
|
||||||
|
firedRef.current = true;
|
||||||
|
callback({ clientX: x, clientY: y, target });
|
||||||
|
}, ms);
|
||||||
|
},
|
||||||
|
[callback, ms, clear],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onTouchMove = useCallback(() => {
|
||||||
|
clear();
|
||||||
|
}, [clear]);
|
||||||
|
|
||||||
|
const onTouchEnd = useCallback(
|
||||||
|
(e: TouchEvent) => {
|
||||||
|
clear();
|
||||||
|
if (firedRef.current && suppressClickOnFire) {
|
||||||
|
// Block the synthetic click that follows touchend; the long-press
|
||||||
|
// already handled the gesture.
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[clear, suppressClickOnFire],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onTouchCancel = useCallback(
|
||||||
|
(_e: TouchEvent) => {
|
||||||
|
clear();
|
||||||
|
},
|
||||||
|
[clear],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { onTouchStart, onTouchMove, onTouchEnd, onTouchCancel };
|
||||||
|
}
|
||||||
77
apps/web/src/hooks/usePullToRefresh.ts
Normal file
77
apps/web/src/hooks/usePullToRefresh.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { useCallback, useRef, useState } from 'react';
|
||||||
|
import type { TouchEvent } from 'react';
|
||||||
|
|
||||||
|
interface Options {
|
||||||
|
threshold?: number;
|
||||||
|
enabled?: boolean;
|
||||||
|
maxPull?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Handlers {
|
||||||
|
onTouchStart: (e: TouchEvent<HTMLElement>) => void;
|
||||||
|
onTouchMove: (e: TouchEvent<HTMLElement>) => void;
|
||||||
|
onTouchEnd: () => void;
|
||||||
|
pullDist: number;
|
||||||
|
refreshing: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hand-rolled pull-to-refresh: records the initial Y on touchstart only if
|
||||||
|
// the target is scrolled to the top, then tracks downward pull on touchmove.
|
||||||
|
// On touchend, fires onRefresh if the pull exceeded the threshold.
|
||||||
|
export function usePullToRefresh(
|
||||||
|
onRefresh: () => void | Promise<void>,
|
||||||
|
{ threshold = 80, enabled = true, maxPull = 120 }: Options = {},
|
||||||
|
): Handlers {
|
||||||
|
const [pullDist, setPullDist] = useState(0);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const startYRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const onTouchStart = useCallback(
|
||||||
|
(e: TouchEvent<HTMLElement>) => {
|
||||||
|
if (!enabled || refreshing) return;
|
||||||
|
const target = e.currentTarget as HTMLElement;
|
||||||
|
if (target.scrollTop > 0) return;
|
||||||
|
const t = e.touches[0];
|
||||||
|
if (!t) return;
|
||||||
|
startYRef.current = t.clientY;
|
||||||
|
},
|
||||||
|
[enabled, refreshing],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onTouchMove = useCallback(
|
||||||
|
(e: TouchEvent<HTMLElement>) => {
|
||||||
|
if (!enabled || refreshing || startYRef.current === null) return;
|
||||||
|
const t = e.touches[0];
|
||||||
|
if (!t) return;
|
||||||
|
const delta = t.clientY - startYRef.current;
|
||||||
|
if (delta > 0) {
|
||||||
|
setPullDist(Math.min(delta, maxPull));
|
||||||
|
} else {
|
||||||
|
setPullDist(0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[enabled, refreshing, maxPull],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onTouchEnd = useCallback(() => {
|
||||||
|
if (!enabled || refreshing) {
|
||||||
|
startYRef.current = null;
|
||||||
|
setPullDist(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fired = pullDist >= threshold && startYRef.current !== null;
|
||||||
|
startYRef.current = null;
|
||||||
|
setPullDist(0);
|
||||||
|
if (fired) {
|
||||||
|
setRefreshing(true);
|
||||||
|
Promise.resolve(onRefresh())
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => {
|
||||||
|
// Hold the indicator briefly so the action feels intentional.
|
||||||
|
window.setTimeout(() => setRefreshing(false), 600);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [enabled, refreshing, pullDist, threshold, onRefresh]);
|
||||||
|
|
||||||
|
return { onTouchStart, onTouchMove, onTouchEnd, pullDist, refreshing };
|
||||||
|
}
|
||||||
35
apps/web/src/hooks/useRightRailDrawer.tsx
Normal file
35
apps/web/src/hooks/useRightRailDrawer.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
|
interface RightRailDrawerState {
|
||||||
|
open: boolean;
|
||||||
|
setOpen: (open: boolean) => void;
|
||||||
|
toggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Ctx = createContext<RightRailDrawerState | null>(null);
|
||||||
|
|
||||||
|
export function RightRailDrawerProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
// Auto-close on route change. Same pattern as useSidebarDrawer — keeps the
|
||||||
|
// drawer from leaking between sessions when the user navigates.
|
||||||
|
useEffect(() => {
|
||||||
|
setOpen(false);
|
||||||
|
}, [location.pathname]);
|
||||||
|
|
||||||
|
const toggle = useCallback(() => setOpen((v) => !v), []);
|
||||||
|
|
||||||
|
return <Ctx.Provider value={{ open, setOpen, toggle }}>{children}</Ctx.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRightRailDrawer(): RightRailDrawerState {
|
||||||
|
const ctx = useContext(Ctx);
|
||||||
|
if (!ctx) {
|
||||||
|
// Soft fallback so consumers don't crash if rendered outside a provider.
|
||||||
|
return { open: false, setOpen: () => {}, toggle: () => {} };
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -2,6 +2,10 @@ import { useEffect, useRef, useState } from 'react';
|
|||||||
import type { Message, WsFrame } from '@/api/types';
|
import type { Message, WsFrame } from '@/api/types';
|
||||||
import { sessionEvents } from './sessionEvents';
|
import { sessionEvents } from './sessionEvents';
|
||||||
|
|
||||||
|
// session_renamed frame removed from WsFrame — it was declared but never
|
||||||
|
// published on the per-session WS channel (server publishes via broker.publishUser
|
||||||
|
// since v1.4). chat_renamed remains; auto_name.ts publishes it on session WS.
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
messages: Message[];
|
messages: Message[];
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
@@ -118,14 +122,6 @@ function applyFrame(state: State, frame: WsFrame): State {
|
|||||||
messages: state.messages.filter((m) => !removeSet.has(m.id)),
|
messages: state.messages.filter((m) => !removeSet.has(m.id)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case 'session_renamed': {
|
|
||||||
sessionEvents.emit({
|
|
||||||
type: 'session_renamed',
|
|
||||||
session_id: frame.session_id,
|
|
||||||
name: frame.name,
|
|
||||||
});
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
case 'chat_renamed': {
|
case 'chat_renamed': {
|
||||||
sessionEvents.emit({
|
sessionEvents.emit({
|
||||||
type: 'chat_updated',
|
type: 'chat_updated',
|
||||||
|
|||||||
36
apps/web/src/hooks/useSidebarDrawer.tsx
Normal file
36
apps/web/src/hooks/useSidebarDrawer.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
|
interface SidebarDrawerState {
|
||||||
|
open: boolean;
|
||||||
|
setOpen: (open: boolean) => void;
|
||||||
|
toggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Ctx = createContext<SidebarDrawerState | null>(null);
|
||||||
|
|
||||||
|
export function SidebarDrawerProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
// Auto-close on navigation. Effect fires once on mount too (open default
|
||||||
|
// is false, so no observable effect) and on every pathname change after.
|
||||||
|
useEffect(() => {
|
||||||
|
setOpen(false);
|
||||||
|
}, [location.pathname]);
|
||||||
|
|
||||||
|
const toggle = useCallback(() => setOpen((v) => !v), []);
|
||||||
|
|
||||||
|
return <Ctx.Provider value={{ open, setOpen, toggle }}>{children}</Ctx.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSidebarDrawer(): SidebarDrawerState {
|
||||||
|
const ctx = useContext(Ctx);
|
||||||
|
if (!ctx) {
|
||||||
|
// Soft fallback so consumers don't crash if rendered outside a provider.
|
||||||
|
// In practice all top-level routes are inside the provider.
|
||||||
|
return { open: false, setOpen: () => {}, toggle: () => {} };
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -40,7 +40,8 @@ export function useUserEvents(): void {
|
|||||||
};
|
};
|
||||||
|
|
||||||
ws.onerror = () => {
|
ws.onerror = () => {
|
||||||
// close handler will trigger reconnect
|
// close handler will trigger reconnect; best-effort, ignore failure
|
||||||
|
// because the socket may already be closing
|
||||||
try { ws?.close(); } catch {}
|
try { ws?.close(); } catch {}
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -50,6 +51,7 @@ export function useUserEvents(): void {
|
|||||||
return () => {
|
return () => {
|
||||||
unmounted = true;
|
unmounted = true;
|
||||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||||
|
// best-effort cleanup; ignore failure because the socket may already be closed
|
||||||
if (ws) try { ws.close(); } catch {}
|
if (ws) try { ws.close(); } catch {}
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
45
apps/web/src/hooks/useViewport.ts
Normal file
45
apps/web/src/hooks/useViewport.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
// Breakpoints (px): mobile <768, tablet 768-1023, desktop >=1024.
|
||||||
|
const MOBILE_MAX = 767;
|
||||||
|
const TABLET_MAX = 1023;
|
||||||
|
|
||||||
|
export interface ViewportSnapshot {
|
||||||
|
isMobile: boolean;
|
||||||
|
isTablet: boolean;
|
||||||
|
width: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshot(): ViewportSnapshot {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return { isMobile: false, isTablet: false, width: 1280 };
|
||||||
|
}
|
||||||
|
const width = window.innerWidth;
|
||||||
|
return {
|
||||||
|
isMobile: width <= MOBILE_MAX,
|
||||||
|
isTablet: width > MOBILE_MAX && width <= TABLET_MAX,
|
||||||
|
width,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchMedia-based, no resize polling. We listen to two breakpoint queries
|
||||||
|
// and recompute the snapshot on any change.
|
||||||
|
export function useViewport(): ViewportSnapshot {
|
||||||
|
const [state, setState] = useState<ViewportSnapshot>(snapshot);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
const mobileMq = window.matchMedia(`(max-width: ${MOBILE_MAX}px)`);
|
||||||
|
const tabletMq = window.matchMedia(`(min-width: ${MOBILE_MAX + 1}px) and (max-width: ${TABLET_MAX}px)`);
|
||||||
|
const update = () => setState(snapshot());
|
||||||
|
mobileMq.addEventListener('change', update);
|
||||||
|
tabletMq.addEventListener('change', update);
|
||||||
|
update();
|
||||||
|
return () => {
|
||||||
|
mobileMq.removeEventListener('change', update);
|
||||||
|
tabletMq.removeEventListener('change', update);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||||
import { Plus, MessageSquare, Trash2, ChevronDown, ChevronRight, RotateCcw } from 'lucide-react';
|
import { Plus, MessageSquare, Trash2, ChevronDown, ChevronRight, RotateCcw, Menu } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '@/api/client';
|
import { api } from '@/api/client';
|
||||||
import type { Project as ProjectType, Session } from '@/api/types';
|
import type { Project as ProjectType, Session } from '@/api/types';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||||
import { useSessions } from '@/hooks/useSessions';
|
import { useSessions } from '@/hooks/useSessions';
|
||||||
|
import { useSidebarDrawer } from '@/hooks/useSidebarDrawer';
|
||||||
|
import { useViewport } from '@/hooks/useViewport';
|
||||||
|
|
||||||
export function Project() {
|
export function Project() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
@@ -16,6 +18,8 @@ export function Project() {
|
|||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
const [archivedSessions, setArchivedSessions] = useState<Session[] | null>(null);
|
const [archivedSessions, setArchivedSessions] = useState<Session[] | null>(null);
|
||||||
const [showArchived, setShowArchived] = useState(false);
|
const [showArchived, setShowArchived] = useState(false);
|
||||||
|
const { setOpen: setDrawerOpen } = useSidebarDrawer();
|
||||||
|
const { isMobile } = useViewport();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
@@ -76,18 +80,33 @@ export function Project() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col">
|
<div className="flex-1 flex flex-col">
|
||||||
<header className="border-b px-6 py-3 flex items-center justify-between">
|
<header
|
||||||
<div>
|
className="border-b px-3 sm:px-6 py-2 sm:py-3 flex items-center justify-between gap-2"
|
||||||
<h1 className="text-lg font-semibold tracking-tight">
|
style={{ paddingTop: 'max(0.5rem, env(safe-area-inset-top))' }}
|
||||||
{project?.name ?? '…'}
|
>
|
||||||
</h1>
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<div className="text-xs text-muted-foreground font-mono">
|
{isMobile && (
|
||||||
{project?.path}
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDrawerOpen(true)}
|
||||||
|
className="inline-flex items-center justify-center -ml-1 min-w-[44px] min-h-[44px] rounded text-muted-foreground hover:bg-muted hover:text-foreground shrink-0"
|
||||||
|
aria-label="Open sidebar"
|
||||||
|
>
|
||||||
|
<Menu className="size-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h1 className="text-base sm:text-lg font-semibold tracking-tight truncate">
|
||||||
|
{project?.name ?? '…'}
|
||||||
|
</h1>
|
||||||
|
<div className="text-xs text-muted-foreground font-mono truncate hidden sm:block">
|
||||||
|
{project?.path}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={handleNew} disabled={creating}>
|
<Button onClick={handleNew} disabled={creating} className="shrink-0" aria-label="New session">
|
||||||
<Plus />
|
<Plus />
|
||||||
New session
|
<span className="hidden sm:inline">New session</span>
|
||||||
</Button>
|
</Button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||||
import { ChevronRight } from 'lucide-react';
|
import { ChevronRight, FolderTree, Menu } from 'lucide-react';
|
||||||
import { api } from '@/api/client';
|
import { api } from '@/api/client';
|
||||||
import type { Project, Session as SessionType } from '@/api/types';
|
import type { Project, Session as SessionType } from '@/api/types';
|
||||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||||
import { useActivePane } from '@/hooks/useActivePane';
|
import { useActivePane } from '@/hooks/useActivePane';
|
||||||
|
import { useSidebarDrawer } from '@/hooks/useSidebarDrawer';
|
||||||
|
import { useRightRailDrawer } from '@/hooks/useRightRailDrawer';
|
||||||
|
import { useViewport } from '@/hooks/useViewport';
|
||||||
import { Workspace } from '@/components/Workspace';
|
import { Workspace } from '@/components/Workspace';
|
||||||
import { ModelPicker } from '@/components/ModelPicker';
|
import { ModelPicker } from '@/components/ModelPicker';
|
||||||
|
|
||||||
@@ -16,6 +19,9 @@ export function Session() {
|
|||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [editingName, setEditingName] = useState(false);
|
const [editingName, setEditingName] = useState(false);
|
||||||
const active = useActivePane();
|
const active = useActivePane();
|
||||||
|
const { setOpen: setDrawerOpen } = useSidebarDrawer();
|
||||||
|
const { toggle: toggleRightRail } = useRightRailDrawer();
|
||||||
|
const { isMobile } = useViewport();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
@@ -38,9 +44,9 @@ export function Session() {
|
|||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
const p = projects.find((x) => x.id === s.project_id);
|
const p = projects.find((x) => x.id === s.project_id);
|
||||||
if (p) setProject(p);
|
if (p) setProject(p);
|
||||||
}).catch(() => {});
|
}).catch((err) => console.warn('Session: failed to load project for breadcrumb', err));
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch((err) => console.warn('Session: failed to fetch session', err));
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
@@ -83,23 +89,42 @@ export function Session() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col min-h-0">
|
<div className="flex-1 flex flex-col min-h-0">
|
||||||
<header className="border-b px-4 py-2 flex items-center gap-1.5 shrink-0 text-sm">
|
<header
|
||||||
<Link to="/" className="text-muted-foreground hover:text-foreground">
|
className="border-b px-3 sm:px-4 py-2 flex items-center gap-1.5 shrink-0 text-sm"
|
||||||
Projects
|
style={{ paddingTop: 'max(0.5rem, env(safe-area-inset-top))' }}
|
||||||
</Link>
|
>
|
||||||
<ChevronRight className="size-3 text-muted-foreground/60" />
|
{isMobile && (
|
||||||
{project ? (
|
<button
|
||||||
<Link
|
type="button"
|
||||||
to={`/project/${project.id}`}
|
onClick={() => setDrawerOpen(true)}
|
||||||
className="text-muted-foreground hover:text-foreground truncate max-w-[200px]"
|
className="inline-flex items-center justify-center -ml-1 min-w-[44px] min-h-[44px] rounded text-muted-foreground hover:bg-muted hover:text-foreground shrink-0"
|
||||||
title={project.name}
|
aria-label="Open sidebar"
|
||||||
>
|
>
|
||||||
{project.name}
|
<Menu className="size-5" />
|
||||||
</Link>
|
</button>
|
||||||
) : (
|
|
||||||
<span className="text-muted-foreground/60">…</span>
|
|
||||||
)}
|
)}
|
||||||
<ChevronRight className="size-3 text-muted-foreground/60" />
|
|
||||||
|
{/* Breadcrumb — desktop only */}
|
||||||
|
<div className="hidden sm:flex items-center gap-1.5 min-w-0">
|
||||||
|
<Link to="/" className="text-muted-foreground hover:text-foreground shrink-0 text-xs">
|
||||||
|
Projects
|
||||||
|
</Link>
|
||||||
|
<ChevronRight className="size-3 text-muted-foreground/60 shrink-0" />
|
||||||
|
{project ? (
|
||||||
|
<Link
|
||||||
|
to={`/project/${project.id}`}
|
||||||
|
className="text-muted-foreground hover:text-foreground truncate max-w-[200px]"
|
||||||
|
title={project.name}
|
||||||
|
>
|
||||||
|
{project.name}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground/60">…</span>
|
||||||
|
)}
|
||||||
|
<ChevronRight className="size-3 text-muted-foreground/60 shrink-0" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Session name — always visible, truncated, editable */}
|
||||||
{editingName ? (
|
{editingName ? (
|
||||||
<input
|
<input
|
||||||
autoFocus
|
autoFocus
|
||||||
@@ -113,30 +138,34 @@ export function Session() {
|
|||||||
setEditingName(false);
|
setEditingName(false);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="bg-transparent border-b border-border px-1 py-0.5 text-sm font-medium outline-none focus:border-ring"
|
className="bg-transparent border-b border-border px-1 py-0.5 text-sm font-medium outline-none focus:border-ring min-w-0"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="text-sm font-medium hover:underline truncate max-w-[280px]"
|
className="text-sm font-medium hover:underline truncate max-w-[140px] sm:max-w-[280px] min-w-0"
|
||||||
onClick={() => setEditingName(true)}
|
onClick={() => setEditingName(true)}
|
||||||
title={session?.name ?? ''}
|
title={session?.name ?? ''}
|
||||||
>
|
>
|
||||||
{session?.name ?? '…'}
|
{session?.name ?? '…'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Active file — desktop only */}
|
||||||
{showActiveFile && active.activeFile && (
|
{showActiveFile && active.activeFile && (
|
||||||
<>
|
<>
|
||||||
<span className="text-muted-foreground/40 mx-1">·</span>
|
<span className="text-muted-foreground/40 mx-1 hidden sm:inline">·</span>
|
||||||
<span
|
<span
|
||||||
className="text-xs font-mono text-muted-foreground truncate max-w-[320px]"
|
className="text-xs font-mono text-muted-foreground truncate max-w-[200px] hidden sm:inline"
|
||||||
title={active.activeFile}
|
title={active.activeFile}
|
||||||
>
|
>
|
||||||
{active.activeFile}
|
{active.activeFile}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<div className="ml-auto">
|
|
||||||
|
{/* Model picker — right-aligned */}
|
||||||
|
<div className="ml-auto shrink-0">
|
||||||
{session && (
|
{session && (
|
||||||
<div className="inline-flex items-center rounded-full bg-muted/40 hover:bg-muted/70 px-1">
|
<div className="inline-flex items-center rounded-full bg-muted/40 hover:bg-muted/70 px-1">
|
||||||
<ModelPicker
|
<ModelPicker
|
||||||
@@ -149,6 +178,18 @@ export function Session() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* File browser toggle — mobile only */}
|
||||||
|
{isMobile && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleRightRail}
|
||||||
|
className="inline-flex items-center justify-center -mr-1 min-w-[44px] min-h-[44px] rounded text-muted-foreground hover:bg-muted hover:text-foreground shrink-0"
|
||||||
|
aria-label="Toggle file browser"
|
||||||
|
>
|
||||||
|
<FolderTree className="size-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{id && session && (
|
{id && session && (
|
||||||
|
|||||||
515
boocode_roadmap.md
Normal file
515
boocode_roadmap.md
Normal file
@@ -0,0 +1,515 @@
|
|||||||
|
# BooCode v1.x — Roadmap
|
||||||
|
|
||||||
|
Last updated: 2026-05-16
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
BooCode is a standalone code-chat tool at `/opt/boocode/`. Read-only by design in v1.x — pick a project, chat with a local LLM that has file-inspection tools, get streaming responses over WebSocket. Built May 2026 after the in-boolab BooCode mode stalled.
|
||||||
|
|
||||||
|
v1 shipped in a single Claude Code session. v1.1 onwards is a batched build-out. Original Batch 1–10 plan was reordered mid-stream — chats-inside-sessions, archive, and fork/delete work was prioritized over the mobile pass.
|
||||||
|
|
||||||
|
Live at `https://code.indifferentketchup.com` (Caddy → Authelia → Tailscale → `100.114.205.53:9500`).
|
||||||
|
|
||||||
|
-----
|
||||||
|
|
||||||
|
## Version summary
|
||||||
|
|
||||||
|
|Version |Theme |Status |Notes |
|
||||||
|
|----------------|---------------------------------------------------------------------------------------------------------------------------------------|---------------------------------|-----------------------------------------|
|
||||||
|
|v1.0 |Initial scaffold, read-only tools, WS streaming |✅ Done |Shipped in one Claude Code session |
|
||||||
|
|v1.1-batch1 |Markdown, Copy + Regen, tok/s + ctx, AI chat naming |✅ Merged |— |
|
||||||
|
|v1.1-batch2 |Sidebar restructure: projects → sessions, max 5 + “view all” |✅ Merged |— |
|
||||||
|
|v1.1-batch3 |Pane system, FileBrowserPane + Shiki, chat→file click, cross-tab |✅ Merged |— |
|
||||||
|
|v1.1-batch3.5 |Chip infrastructure, `@file` picker, line-select-attach |✅ Merged |— |
|
||||||
|
|v1.2 |Chats inside sessions refactor, right-rail, `/compact`, archive, force-send |✅ Merged |Replaced original “Batch 4 = mobile” plan|
|
||||||
|
|v1.2-project-ux |Project archive UX, sidebar context menu, full-bootstrap, Gitea API |✅ Merged |— |
|
||||||
|
|v1.3 |Tab-close + chat-archive |✅ Merged |— |
|
||||||
|
|v1.4 |Fork from message + delete message + header polish + housekeeping |✅ Merged |Was original “Batch 5” |
|
||||||
|
|v1.5 |Refactor splits, vitest harness (23 tests), error-log surfacing, `/opt:ro` + `BOOTSTRAP_ROOT`, persistent context-window tracker |✅ Merged |— |
|
||||||
|
|v1.5.1 |Bootstrap hotfix: git in container, SSH keypair, known_hosts, SSH URL rewrite, /opt/projects label |✅ Merged |`4a9f207` |
|
||||||
|
|v1.6-mobile-pass|Mobile pass: drawer, pane stacking, long-press, swipe-to-close, pull-to-refresh, IME safety, safe-area, tap targets + H1 path-guard fix|✅ Merged |`57c883b..943ae7d` (6 commits) |
|
||||||
|
|v1.6.1-cleanup |Mostly audit-only; one fix shipped: RightRail `max-md:hidden` wrapper. Audit reports for secrets, stale code, panes, mount scope, hand-rolled patterns deferred to follow-ups |✅ Merged |`6a9fe18` |
|
||||||
|
|v1.6.2-mobile-ui-fixes|Mobile UI polish from device testing: kill single-pane navigator chrome, header rework, “New chat” in long-press menu, RightRail as mobile drawer (reverts v1.6.1 wrapper) |🔄 Hand-back received, uncommitted|— |
|
||||||
|
|v1.7 |Drag-drop + paste-as-attachment (chip infra extension) |Planned |Was Batch 6 |
|
||||||
|
|v1.8 |Settings drawer (system prompt per project + session, web search toggle) |Planned |Was Batch 7 |
|
||||||
|
|v1.9 |Web search backend: SearXNG `web_search` + `web_fetch` tools |Planned |Was Batch 8 |
|
||||||
|
|v1.10 |Agents (Tier 2): `AGENTS.md`, per-agent model/temp/tools, picker |Planned |Was Batch 9 |
|
||||||
|
|v1.11 |BooTerm: separate container, xterm.js + node-pty + tmux, terminal pane |Planned |Was Batch 10 |
|
||||||
|
|
||||||
|
-----
|
||||||
|
|
||||||
|
## Version details
|
||||||
|
|
||||||
|
### v1.1-batch1 — Message polish ✅
|
||||||
|
|
||||||
|
Markdown (`react-markdown` + `remark-gfm`), Copy + Regenerate, tok/s + context counter, AI session naming.
|
||||||
|
|
||||||
|
**Key decisions:**
|
||||||
|
|
||||||
|
- `sessions.name` (not `title`).
|
||||||
|
- `enable_thinking: false` + `max_tokens: 30` for Qwen3 utility calls.
|
||||||
|
- `messages_deleted` WS frame added for multi-tab regen.
|
||||||
|
- In-app event bus (`sessionEvents.ts`, module-scope `Set<Listener>`).
|
||||||
|
|
||||||
|
**Schema:** `messages.tokens_used`, `messages.ctx_used`, `messages.ctx_max`, `messages.started_at`, `messages.finished_at`.
|
||||||
|
|
||||||
|
-----
|
||||||
|
|
||||||
|
### v1.1-batch2 — Sidebar restructure ✅
|
||||||
|
|
||||||
|
Projects as expandable groups, up to 5 recent sessions per project, “View all (N)”, `GET /api/sidebar`, `useSidebar` singleton hook.
|
||||||
|
|
||||||
|
**Key decisions:**
|
||||||
|
|
||||||
|
- `useSidebar` module-scope singleton.
|
||||||
|
- `localStorage['boocode.sidebar.expanded']`.
|
||||||
|
- `session_renamed` payload `{session_id, name}`.
|
||||||
|
|
||||||
|
-----
|
||||||
|
|
||||||
|
### v1.1-batch3 — Pane system ✅
|
||||||
|
|
||||||
|
`session_panes` table, pane CRUD with transactional position-shift, Workspace + tab strip + drag-to-reorder (native HTML5), ChatPane (extracted), FileBrowserPane (tree + Shiki + filter), chat→file click, PaneTab context menu, `file_ops` + `file_index` shared services, broker user channel + `/ws/user`, `session_updated`, `session_loaded`, idempotent default-Chat-pane backfill.
|
||||||
|
|
||||||
|
**Schema:** `session_panes` (id, session_id, position, kind CHECK, state JSONB).
|
||||||
|
|
||||||
|
-----
|
||||||
|
|
||||||
|
### v1.1-batch3.5 — Chips + @file + line-select ✅
|
||||||
|
|
||||||
|
`Attachment` type + `flattenToMessage` + LANG_MAP, `AttachmentChip`, `AttachmentPreviewModal`, ChatInput chip-row, hand-rolled `@file` mention popover, line-select-attach in FileBrowserPane via local `FileViewer`, FileBrowserPane filter upgrade (empty=tree, non-empty=flat).
|
||||||
|
|
||||||
|
-----
|
||||||
|
|
||||||
|
### v1.2 — Chats inside sessions ✅
|
||||||
|
|
||||||
|
Originally planned as the mobile pass. Reshuffled: structural refactor — chats inside sessions, right-rail, `/compact` (chat’s own model summarizes via `kind='compact'` system message), force-send.
|
||||||
|
|
||||||
|
**Schema:** `chats` table, `sessions.status`, `messages.chat_id`, `messages.kind` (regular | compact).
|
||||||
|
|
||||||
|
-----
|
||||||
|
|
||||||
|
### v1.2-project-ux ✅
|
||||||
|
|
||||||
|
Full new-project bootstrap (mkdir + git init + .gitignore + first commit + Gitea remote + push), sidebar context menu (Rename / Archive / Open in Gitea), project landing page archived-list, Gitea API integration. Option B taken: `BOOTSTRAP_ROOT` env var, `/opt` stays read-only mount, `/opt/projects` writable.
|
||||||
|
|
||||||
|
**Schema:** `projects.status`, `projects.archived_at`.
|
||||||
|
|
||||||
|
**Key decisions:**
|
||||||
|
|
||||||
|
- `execFile` only, no `exec` shell strings.
|
||||||
|
- DB INSERT last in bootstrap sequence.
|
||||||
|
- Soft-fail on Gitea steps.
|
||||||
|
- Project Delete endpoint exists but stays unexposed (re-add INSERTs fresh row → FK cascade nukes history; archive is the safe pattern).
|
||||||
|
|
||||||
|
-----
|
||||||
|
|
||||||
|
### v1.3 — Tab close + chat archive ✅
|
||||||
|
|
||||||
|
Tab close UX cleanup, chat-level archive (separate from session archive).
|
||||||
|
|
||||||
|
-----
|
||||||
|
|
||||||
|
### v1.4 — Fork + delete + header polish ✅
|
||||||
|
|
||||||
|
Was originally planned as Batch 5.
|
||||||
|
|
||||||
|
**Shipped:** `POST /api/sessions/:id/fork` (deep copy messages up to target, new session in same project), `DELETE /api/sessions/:id/messages/:id` (cascading via `messages_deleted` frame), header breadcrumb (Projects → Project → Session), inline-editable session name, file path shown when File Browser pane is active, `useActivePane` hook.
|
||||||
|
|
||||||
|
-----
|
||||||
|
|
||||||
|
### v1.5 — Refactor + tests + security scoping + context tracker ✅
|
||||||
|
|
||||||
|
5-commit sequence:
|
||||||
|
|
||||||
|
1. **Refactor:** FileBrowserPane (865 → split with FileViewer extracted), Workspace, inference split.
|
||||||
|
1. **Vitest harness:** 23 tests covering routes + resolveProjectPath. Pinned to v3 (Vite 5 / vitest 4 incompatibility).
|
||||||
|
1. **Error-log surfacing:** dead-code removal from earlier H1/H2 audit items, structured error logs to client.
|
||||||
|
1. **Mount scoping:** `/opt:/opt:ro` + `BOOTSTRAP_ROOT` writable subdir. Container loses write to `/opt` proper.
|
||||||
|
1. **Persistent context-window tracker:** floating popover above chat input right edge, source = latest `message_complete` frame’s `ctx_used` / `ctx_max`, color-coded (neutral <60%, amber 60–85%, red 85%+), hides when `ctx_max` null.
|
||||||
|
|
||||||
|
**Carried bug:** `resolveProjectPath` whitelist-root bypass — discovered, asserted as “BEHAVIOR GAP” rather than silently patched. Fix landed in v1.6 (H1).
|
||||||
|
|
||||||
|
-----
|
||||||
|
|
||||||
|
### v1.5.1 — Bootstrap hotfix ✅ (`4a9f207`)
|
||||||
|
|
||||||
|
Dockerfile (git installed in container), docker-compose.yml, project_bootstrap.ts (SSH keypair, known_hosts, SSH URL Tailscale rewrite), CreateProjectModal.tsx, .gitignore. /opt/projects label clarified.
|
||||||
|
|
||||||
|
**Known issue carried forward:** dispatch used the in-repo `secrets/boocode_gitea` SSH key because the agent key was rejected. Key exposure flagged. Audit + rotation tracked in v1.6.1 below.
|
||||||
|
|
||||||
|
-----
|
||||||
|
|
||||||
|
### v1.6-mobile-pass ✅
|
||||||
|
|
||||||
|
**Merged via 6 commits `57c883b..943ae7d`** (5 functional + 1 docs):
|
||||||
|
|
||||||
|
1. `57c883b chore: fix resolveProjectPath whitelist-root bypass` (H1 — dropped `real !== whitelistReal` short-circuit; flipped the v1.5 BEHAVIOR GAP test; 23/23 pass).
|
||||||
|
1. `a643b5f feat(mobile): viewport hook + sidebar drawer + hamburger headers` (M1 + M2 + M6-header).
|
||||||
|
1. `cd897d6 feat(mobile): single-pane stack + long-press tab menu + swipe-to-close` (M3 + M4 + A2).
|
||||||
|
1. `273eeac feat(mobile): chat input keybinds + safe-area + tap targets + overflow safety` (M5 + M6-bottom + M7 + M8).
|
||||||
|
1. `4b5b9b2 feat(mobile): pull-to-refresh sidebar list` (A1).
|
||||||
|
1. `943ae7d docs: add v1.x roadmap snapshot` (this file).
|
||||||
|
|
||||||
|
**Decisions:**
|
||||||
|
|
||||||
|
- H2 (roadmap update) handled in this file rather than by Claude Code.
|
||||||
|
- M5: mobile = button-only send, Enter inserts newline. Desktop unchanged. `isComposing` guard for CJK IME.
|
||||||
|
- M6: kept `max-w-[1000px]` (mobile naturally full-width below cap).
|
||||||
|
- URL state: `?pane=<paneId>`. Bare URL resets activePaneIdx to 0.
|
||||||
|
- Long-press dispatches synthetic `contextmenu` on `[data-tab-id]`, opening Radix ContextMenuTrigger at touch coords. iOS callout suppressed.
|
||||||
|
- `SwipeablePaneTab`: 60px threshold, bails if vertical >30px, opacity 1→0.4.
|
||||||
|
- A2 bundled with M3 in Commit 3 (structural coupling).
|
||||||
|
- Home.tsx no hamburger.
|
||||||
|
|
||||||
|
**Deferred from v1.6 → rolled into v1.6.1-cleanup:**
|
||||||
|
|
||||||
|
- RightRail still renders on mobile (~32px column).
|
||||||
|
- Secrets hygiene audit.
|
||||||
|
- `ProjectSidebar.tsx` and `ChatTabBar.tsx` share content from two commits each — use `git add -p`.
|
||||||
|
|
||||||
|
-----
|
||||||
|
|
||||||
|
### v1.6.1-cleanup ✅ (`6a9fe18`)
|
||||||
|
|
||||||
|
**Shipped:** RightRail wrapped in `<div className="max-md:hidden contents">` so it's hidden entirely below the md breakpoint on mobile. (Note: v1.6.2 reverses this and replaces with a proper mobile drawer — see below.)
|
||||||
|
|
||||||
|
**Audited but not shipped (queued for follow-ups):**
|
||||||
|
|
||||||
|
- **Secrets hygiene:** `secrets/boocode_gitea` is NOT tracked; never committed to any branch; `.gitignore` already covers `secrets/`. Rotation is a Gitea-side action, no repo change needed.
|
||||||
|
- **`.bak` files:** 3 leftover from v1.5.1 (`docker-compose.yml.bak-20260516`, `Dockerfile.bak-20260516`, `apps/web/src/components/CreateProjectModal.tsx.bak-20260516`). Git-invisible via global `~/.gitignore_global` (`*.bak*`). Decide per file.
|
||||||
|
- **Unused exports:** neither `knip` nor `ts-prune` installed. Proposal pending.
|
||||||
|
- **Dead WS frames:** `session_renamed` HAS a server publisher (`routes/sessions.ts:140`, added in v1.4) — the roadmap's "no server publisher" open item is **STALE**, crossed off. The `InferenceFrame` union still declares `session_renamed` as a type variant but no code publishes it on the per-session channel; trivial 1-line cleanup deferred.
|
||||||
|
- **Unused imports:** web `tsc --noUnusedLocals --noUnusedParameters` returns 0 warnings.
|
||||||
|
- **`useSessionStream` refcount:** opportunity confirmed (~90 lines diff to apply the `useSidebar`-style module-scope singleton pattern). Risk LOW. Queued for v1.6.2 or later.
|
||||||
|
- **PATCH `/api/panes/:id` ownership:** **MOOT** — endpoint does not exist (the pane REST API was never re-introduced after pane state moved to client-side localStorage in v1.2). Crossed off open items.
|
||||||
|
- **Hand-rolled patterns vs library:** 5 hand-rolled hooks/components total 336 lines. None duplicates anything in existing deps; library swap (`@use-gesture`, `react-pull-to-refresh`) not worth the dep cost yet.
|
||||||
|
- **`/opt:/opt:ro` mount tightening:** Two-option plan documented for v1.6.2 — Option A (per-project bind-mounts) or Option B (deny `.env` pattern in `pathGuard`). Option B is the simpler short-term fix.
|
||||||
|
|
||||||
|
-----
|
||||||
|
|
||||||
|
### v1.6.2-mobile-ui-fixes 🔄
|
||||||
|
|
||||||
|
**Hand-back received, uncommitted on `v1.6.2-mobile-ui-fixes`.** 4-commit sequence proposed:
|
||||||
|
|
||||||
|
1. `fix(mobile): hide Split button + single-pane navigator chrome` (G1 — wrap the Workspace Split row in `!isMobile`).
|
||||||
|
1. `feat(mobile): rework Session and Project headers for narrow viewports` (G2 — breadcrumb `hidden sm:flex`, session name cap `max-w-[140px] sm:max-w-[280px]`, project page heading `text-base sm:text-lg`, “New session” icon-only on mobile).
|
||||||
|
1. `feat(mobile): add "New chat" to tab long-press context menu` (G3 — top of menu, separator, then existing items).
|
||||||
|
1. `feat(mobile): right-rail as drawer on mobile, header toggle button` (G4 option b — new `useRightRailDrawer` Context hook, `RightRail` renders as fixed `w-[85vw] max-w-sm` drawer on mobile, FolderTree button in Session header, **reverts v1.6.1's `max-md:hidden` wrapper**).
|
||||||
|
|
||||||
|
**Decisions:**
|
||||||
|
|
||||||
|
- G4 option b chosen: mobile file browsing IS useful; drawer pattern mirrors `useSidebarDrawer`.
|
||||||
|
- G2 single-row session-name+model layout (model picker right-aligned), per spec example.
|
||||||
|
- G3 "New chat" at top, separator, then Rename.
|
||||||
|
- G2 "New session" button: icon-only on mobile via `<span className="hidden sm:inline">New session</span>`.
|
||||||
|
|
||||||
|
**Adjacent uncommitted change (not part of v1.6.2):** `MAX_TOOL_LOOP_DEPTH 5 → 15` in `apps/server/src/services/inference.ts`. Sam-authored, sitting in working tree on `v1.6.2-mobile-ui-fixes`. **NOT on main as of this update.** Commit separately.
|
||||||
|
|
||||||
|
-----
|
||||||
|
|
||||||
|
### v1.7 — Drag-drop + paste (planned, was Batch 6)
|
||||||
|
|
||||||
|
**Depends on:** v1.6.1 merged.
|
||||||
|
|
||||||
|
**Scope (trimmed — chip infra exists from v1.1-batch3.5):**
|
||||||
|
|
||||||
|
- Drag-drop files onto ChatInput → chip via `addAttachment({kind: 'file', source: 'drop'})`.
|
||||||
|
- Paste >8 lines → chip via `addAttachment({kind: 'paste', source: 'paste'})`. ≤8 lines inline.
|
||||||
|
- Drop overlay (dashed border + “Drop to attach”).
|
||||||
|
- Client-side 5 MB cap + binary detection (null-byte check in first 8KB).
|
||||||
|
- Max 10 attachments shared cap.
|
||||||
|
- Folder drop rejected. Image paste rejected. Binary files rejected with toast.
|
||||||
|
|
||||||
|
**Frontend only.**
|
||||||
|
|
||||||
|
-----
|
||||||
|
|
||||||
|
### v1.8 — Settings drawer (planned, was Batch 7)
|
||||||
|
|
||||||
|
**Depends on:** header gear (already in v1.4).
|
||||||
|
|
||||||
|
**Scope:**
|
||||||
|
|
||||||
|
- Right-side drawer (hand-rolled, no shadcn Sheet). Tabbed: Session + Project.
|
||||||
|
- Session tab: system prompt, web search toggle, model picker, session name.
|
||||||
|
- Project tab: default system prompt, default web search, project name, root path (read-only), delete project (consider whether to expose given the cascade concern).
|
||||||
|
- Resolution: `session.system_prompt OR project.default_system_prompt OR ""`.
|
||||||
|
- Project defaults applied at session create (copied), not retroactively.
|
||||||
|
- Web search toggle persistent per session (`sessions.web_search_enabled`).
|
||||||
|
|
||||||
|
**Schema:** `sessions.web_search_enabled`, `projects.default_system_prompt`, `projects.default_web_search`.
|
||||||
|
|
||||||
|
-----
|
||||||
|
|
||||||
|
### v1.9 — Web search backend (planned, was Batch 8)
|
||||||
|
|
||||||
|
**Depends on:** v1.8.
|
||||||
|
|
||||||
|
**Scope:**
|
||||||
|
|
||||||
|
- `web_search` tool → SearXNG at `http://100.114.205.53:8888/search?format=json`, top-N `{title, url, snippet}`.
|
||||||
|
- `web_fetch` tool, regex HTML strip (no cheerio), 50KB cap.
|
||||||
|
- Tools conditionally included based on `session.web_search_enabled`.
|
||||||
|
- `ToolCallCard.tsx` renders results as clickable URL list, web_fetch as text preview.
|
||||||
|
- Env: `SEARXNG_URL`, `WEB_FETCH_TIMEOUT_MS`, `WEB_FETCH_MAX_BYTES`.
|
||||||
|
|
||||||
|
-----
|
||||||
|
|
||||||
|
### v1.10 — Agents (planned, was Batch 9)
|
||||||
|
|
||||||
|
**Depends on:** v1.8.
|
||||||
|
|
||||||
|
**Scope:**
|
||||||
|
|
||||||
|
- Tier 2 agents: system prompt + model + temperature + tools whitelist per agent.
|
||||||
|
- `AGENTS.md` (OpenCode-compatible): `## Agent Name` blocks with YAML frontmatter.
|
||||||
|
- Three builtin defaults (Investigator, Architect, Reviewer) when no `AGENTS.md`.
|
||||||
|
- If `AGENTS.md` exists, only its agents shown.
|
||||||
|
- Agent picker in ChatInput toolbar + SettingsDrawer.
|
||||||
|
- Tools whitelist enforced at inference layer. BooChat agents read-only.
|
||||||
|
- File parsed on demand with mtime cache.
|
||||||
|
- Mid-conversation agent switch allowed; old messages retain their tool history.
|
||||||
|
|
||||||
|
**Schema:** `sessions.agent_id TEXT`.
|
||||||
|
|
||||||
|
-----
|
||||||
|
|
||||||
|
### v1.11 — BooTerm (planned, was Batch 10)
|
||||||
|
|
||||||
|
**Depends on:** v1.1-batch3 (pane system), v1.8 (settings drawer pattern).
|
||||||
|
|
||||||
|
**Scope:**
|
||||||
|
|
||||||
|
- New container `booterm` at `100.114.205.53:9501`. Fastify + node-pty + tmux.
|
||||||
|
- Caddy path-based routing: `/api/term/*` + `/ws/term/*` → booterm.
|
||||||
|
- Shared `boocode_db`.
|
||||||
|
- Per-session tmux (`bc-<session_id>`), per-pane tmux window (`term-<pane_id>`).
|
||||||
|
- xterm.js terminal pane. New `kind = 'terminal'` in `session_panes` CHECK.
|
||||||
|
- PTY over binary WebSocket. Resize via `tmux resize-window`.
|
||||||
|
- Workspace mount: `/opt/repos:/opt/repos:rw`. BooCode chat container keeps `/opt:/opt:ro`.
|
||||||
|
- Send-to-terminal from chat: select text → right-click → “Send to terminal”.
|
||||||
|
- tmux persistence across WS reconnects, page refreshes, container restarts.
|
||||||
|
- No chroot/namespace isolation. Acceptable single-user homelab.
|
||||||
|
|
||||||
|
**New app:** `apps/booterm/`.
|
||||||
|
|
||||||
|
-----
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Containers (current + planned)
|
||||||
|
|
||||||
|
|Container |Port |Mount |Purpose |Status |
|
||||||
|
|------------|---------------------|-----------------------------------|----------------------------|---------|
|
||||||
|
|`boocode` |`100.114.205.53:9500`|`/opt:/opt:ro` + `/opt/projects:rw`|Chat + read-only tools + SPA|Live |
|
||||||
|
|`boocode_db`|`127.0.0.1:5500` |`boocode_pgdata` |Postgres 16-alpine (shared) |Live |
|
||||||
|
|`booterm` |`100.114.205.53:9501`|`/opt/repos:/opt/repos:rw` |Terminal sessions |v1.11 |
|
||||||
|
|`boocoder` |TBD |`/opt/repos:/opt/repos:rw` |Write tools |Post-v1.x|
|
||||||
|
|
||||||
|
### URL routing (target state after v1.11)
|
||||||
|
|
||||||
|
```
|
||||||
|
code.indifferentketchup.com
|
||||||
|
├── / → boocode (SPA)
|
||||||
|
├── /api/chat/*, /ws/chat/* → boocode :9500
|
||||||
|
├── /api/term/*, /ws/term/* → booterm :9501
|
||||||
|
├── /api/coder/*, /ws/coder/* → boocoder (future)
|
||||||
|
└── /ws/user → boocode :9500
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database
|
||||||
|
|
||||||
|
Single Postgres `boocode_db`. All containers share. Projects shared. Sessions container-specific.
|
||||||
|
|
||||||
|
Current schema (post v1.5.1):
|
||||||
|
|
||||||
|
```
|
||||||
|
projects
|
||||||
|
├── id UUID PK
|
||||||
|
├── name TEXT
|
||||||
|
├── root_path TEXT
|
||||||
|
├── status TEXT (v1.2-project-ux: active | archived)
|
||||||
|
├── archived_at TIMESTAMPTZ (v1.2-project-ux)
|
||||||
|
├── default_system_prompt TEXT (v1.8)
|
||||||
|
├── default_web_search BOOLEAN (v1.8)
|
||||||
|
└── created_at TIMESTAMPTZ
|
||||||
|
|
||||||
|
sessions
|
||||||
|
├── id UUID PK
|
||||||
|
├── project_id UUID FK → projects
|
||||||
|
├── name TEXT
|
||||||
|
├── model TEXT
|
||||||
|
├── system_prompt TEXT
|
||||||
|
├── status TEXT (v1.2: active | archived)
|
||||||
|
├── web_search_enabled BOOLEAN (v1.8)
|
||||||
|
├── agent_id TEXT (v1.10)
|
||||||
|
├── created_at TIMESTAMPTZ
|
||||||
|
└── updated_at TIMESTAMPTZ
|
||||||
|
|
||||||
|
chats (v1.2)
|
||||||
|
├── id UUID PK
|
||||||
|
├── session_id UUID FK → sessions
|
||||||
|
├── name TEXT
|
||||||
|
├── status TEXT
|
||||||
|
├── created_at TIMESTAMPTZ
|
||||||
|
└── updated_at TIMESTAMPTZ
|
||||||
|
|
||||||
|
messages
|
||||||
|
├── id UUID PK
|
||||||
|
├── session_id UUID FK → sessions
|
||||||
|
├── chat_id UUID FK → chats (v1.2)
|
||||||
|
├── kind TEXT (v1.2: regular | compact)
|
||||||
|
├── role TEXT
|
||||||
|
├── content TEXT
|
||||||
|
├── tool_calls JSONB
|
||||||
|
├── tool_results JSONB
|
||||||
|
├── status TEXT
|
||||||
|
├── last_seq INTEGER
|
||||||
|
├── tokens_used INTEGER (v1.1-batch1)
|
||||||
|
├── ctx_used INTEGER (v1.1-batch1)
|
||||||
|
├── ctx_max INTEGER (v1.1-batch1)
|
||||||
|
├── started_at TIMESTAMPTZ (v1.1-batch1)
|
||||||
|
├── finished_at TIMESTAMPTZ (v1.1-batch1)
|
||||||
|
└── created_at TIMESTAMPTZ
|
||||||
|
|
||||||
|
session_panes (v1.1-batch3)
|
||||||
|
├── id UUID PK
|
||||||
|
├── session_id UUID FK → sessions (CASCADE)
|
||||||
|
├── position INTEGER
|
||||||
|
├── kind TEXT CHECK (chat | file_browser | terminal)
|
||||||
|
├── state JSONB
|
||||||
|
└── created_at TIMESTAMPTZ
|
||||||
|
|
||||||
|
settings
|
||||||
|
├── k TEXT PK
|
||||||
|
└── v TEXT
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reusable patterns
|
||||||
|
|
||||||
|
|Pattern |Where |Used by |
|
||||||
|
|----------------------------|----------------------------------------|---------------------------------------------------------|
|
||||||
|
|In-app event bus |`sessionEvents.ts` |All batches. Module-scope `Set<Listener>`. |
|
||||||
|
|Singleton hooks |`useSidebar.ts` |Module-scope shared state. |
|
||||||
|
|User-channel WS broker |`broker.ts` + `useUserEvents.ts` |Cross-tab lifecycle. One WS per tab. |
|
||||||
|
|`clock_timestamp()` |All INSERT/UPDATE |Never `NOW()` in new code. |
|
||||||
|
|Additive schema only |`schema.sql` |`ADD COLUMN IF NOT EXISTS`, `CREATE TABLE IF NOT EXISTS`.|
|
||||||
|
|Idempotent backfills |`schema.sql` |`INSERT ... WHERE NOT EXISTS`. |
|
||||||
|
|`enable_thinking: false` |`auto_name.ts` |Required for Qwen3 utility calls. |
|
||||||
|
|`pathGuard` |`tools/*`, `file_ops.ts` |Realpath + project root enforcement. |
|
||||||
|
|Shared `file_ops.ts` |`tools.ts`, `routes/projects.ts` |Same core for inference tools and UI. |
|
||||||
|
|File index (`file_index.ts`)|`routes/projects.ts` |`rg --files` + mtime cache. |
|
||||||
|
|`useViewport` |`hooks/useViewport.ts` (v1.6) |matchMedia, SSR-safe. |
|
||||||
|
|`useSidebarDrawer` |`hooks/useSidebarDrawer.tsx` (v1.6) |Context + auto-close on route change. |
|
||||||
|
|Hand-rolled long-press |`hooks/useLongPress.ts` (v1.6) |500ms touchstart timer, dispatches synthetic contextmenu.|
|
||||||
|
|Hand-rolled pull-to-refresh |`hooks/usePullToRefresh.ts` (v1.6) |80px threshold, 600ms min hold. |
|
||||||
|
|Hand-rolled swipe |`components/SwipeablePaneTab.tsx` (v1.6)|60px threshold, vertical bail at 30px. |
|
||||||
|
|
||||||
|
-----
|
||||||
|
|
||||||
|
## Tech stack
|
||||||
|
|
||||||
|
|Layer |Tech |
|
||||||
|
|----------------|--------------------------------------------------------------------------|
|
||||||
|
|Backend |Node 20 + Fastify + `@fastify/websocket` + `@fastify/static` + zod + `pg` |
|
||||||
|
|Frontend |React + Vite + Tailwind v4 + shadcn nova preset |
|
||||||
|
|Inference |llama-swap `http://100.101.41.16:8401` (OpenAI-compatible) |
|
||||||
|
|Search |SearXNG `http://100.114.205.53:8888` (v1.9) |
|
||||||
|
|Syntax |Shiki (`github-dark`) |
|
||||||
|
|Terminal |xterm.js + node-pty + tmux (v1.11) |
|
||||||
|
|Auth |`Remote-User` from Authelia via Caddy `forward_auth` |
|
||||||
|
|Containerization|Docker Compose, Node 20-alpine, multi-stage, ripgrep apk, git apk (v1.5.1)|
|
||||||
|
|DB |Postgres 16-alpine, loopback `127.0.0.1:5500` |
|
||||||
|
|Networking |Tailscale mesh, Caddy (DO droplet), Authelia SSO |
|
||||||
|
|Code hosting |Gitea `git.indifferentketchup.com` |
|
||||||
|
|Tests |vitest v3 (pinned, Vite 5 / vitest 4 incompatible) |
|
||||||
|
|
||||||
|
-----
|
||||||
|
|
||||||
|
## Known open items
|
||||||
|
|
||||||
|
- **`useSessionStream` refcount.** Two ChatPanes = two WS. Apply singleton pattern. Audited in v1.6.1, queued.
|
||||||
|
- **`/opt:/opt:ro` mount exposes all `.env` files.** Whitelist scope before BooCoder. Two-option plan documented in v1.6.1 audit; ship in v1.6.2 or v1.7.
|
||||||
|
- **`secrets/boocode_gitea` in repo working tree.** Never committed (git-invisible via global ignore). Rotate the Gitea-side key when convenient; no repo action required.
|
||||||
|
- **Dormant in-boolab BooCode mode.** Reference only.
|
||||||
|
- **BooCoder container.** Post-v1.x.
|
||||||
|
|
||||||
|
**Closed since last update:**
|
||||||
|
|
||||||
|
- ~~`session_renamed` no server WS publisher~~ — server publishes via `broker.publishUser` from `routes/sessions.ts:140` (added in v1.4). Confirmed in v1.6.1 audit.
|
||||||
|
- ~~PATCH `/api/panes/:id` lacks session-ownership check~~ — endpoint does not exist; the pane REST API was never re-introduced after v1.2 moved pane state to localStorage.
|
||||||
|
|
||||||
|
-----
|
||||||
|
|
||||||
|
## Dependency graph
|
||||||
|
|
||||||
|
```
|
||||||
|
v1.0 (initial)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
v1.1-batch1 (markdown)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
v1.1-batch2 (sidebar)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
v1.1-batch3 (panes) ────────────────────────┐
|
||||||
|
│ │
|
||||||
|
▼ │
|
||||||
|
v1.1-batch3.5 (chips) ──────┐ │
|
||||||
|
│ │ │
|
||||||
|
▼ │ │
|
||||||
|
v1.2 (chats-in-sessions) │ │
|
||||||
|
│ │ │
|
||||||
|
▼ │ │
|
||||||
|
v1.2-project-ux │ │
|
||||||
|
│ │ │
|
||||||
|
▼ │ │
|
||||||
|
v1.3 (tab-close) │ │
|
||||||
|
│ │ │
|
||||||
|
▼ │ │
|
||||||
|
v1.4 (fork+delete+header) ◄──┼────────────────┘
|
||||||
|
│ │
|
||||||
|
▼ │
|
||||||
|
v1.5 (refactor+tests+ctx) │
|
||||||
|
│ │
|
||||||
|
▼ │
|
||||||
|
v1.5.1 (bootstrap hotfix) │
|
||||||
|
│ │
|
||||||
|
▼ │
|
||||||
|
v1.6-mobile-pass │
|
||||||
|
│ │
|
||||||
|
▼ │
|
||||||
|
v1.6.1-cleanup │
|
||||||
|
│ │
|
||||||
|
▼ │
|
||||||
|
v1.6.2-mobile-ui-fixes ◄─────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
v1.7 (drag-drop) ◄── v1.1-batch3.5
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
v1.8 (settings)
|
||||||
|
│
|
||||||
|
├──▶ v1.9 (web search)
|
||||||
|
│
|
||||||
|
├──▶ v1.10 (agents)
|
||||||
|
│
|
||||||
|
└──▶ v1.11 (BooTerm) ◄── v1.1-batch3
|
||||||
|
```
|
||||||
|
|
||||||
|
-----
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. Verify previous version merged to `main`.
|
||||||
|
1. Dispatch prompt via Paseo (Claude Code runs at `/opt/boocode`).
|
||||||
|
1. Claude Code recon → blocking questions → implement → hand back.
|
||||||
|
1. Review hand-back in separate Claude chat (spec compliance, code quality, drift, stale code).
|
||||||
|
1. Deploy: `docker compose up --build -d`.
|
||||||
|
1. Smoke test per the hand-back’s plan.
|
||||||
|
1. Sam commits manually, pushes to Gitea, merges to `main`.
|
||||||
|
1. Next version.
|
||||||
|
|
||||||
|
Sam reviews all diffs. Sam commits. Never git pull/push/commit on his behalf.
|
||||||
@@ -9,7 +9,12 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgres://boocode:${POSTGRES_PASSWORD}@boocode_db:5432/boocode
|
DATABASE_URL: postgres://boocode:${POSTGRES_PASSWORD}@boocode_db:5432/boocode
|
||||||
volumes:
|
volumes:
|
||||||
- /opt:/opt:rw
|
# Read-only mount for legacy/existing project add-existing flow.
|
||||||
|
- /opt:/opt:ro
|
||||||
|
# Writable mount only for the create-new-project bootstrap target.
|
||||||
|
# Host must `mkdir -p /opt/projects` before container start.
|
||||||
|
- /opt/projects:/opt/projects:rw
|
||||||
|
- ./secrets/boocode_gitea:/root/.ssh/id_ed25519:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
- boocode_db
|
- boocode_db
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
322
pnpm-lock.yaml
generated
322
pnpm-lock.yaml
generated
@@ -45,6 +45,9 @@ importers:
|
|||||||
typescript:
|
typescript:
|
||||||
specifier: ^5.5.0
|
specifier: ^5.5.0
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
|
vitest:
|
||||||
|
specifier: ^3.2.4
|
||||||
|
version: 3.2.4(@types/debug@4.1.13)(@types/node@20.19.41)(lightningcss@1.32.0)(msw@2.14.6(@types/node@20.19.41)(typescript@5.9.3))
|
||||||
|
|
||||||
apps/web:
|
apps/web:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -1697,9 +1700,15 @@ packages:
|
|||||||
'@types/babel__traverse@7.28.0':
|
'@types/babel__traverse@7.28.0':
|
||||||
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
|
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
|
||||||
|
|
||||||
|
'@types/chai@5.2.3':
|
||||||
|
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
||||||
|
|
||||||
'@types/debug@4.1.13':
|
'@types/debug@4.1.13':
|
||||||
resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==}
|
resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==}
|
||||||
|
|
||||||
|
'@types/deep-eql@4.0.2':
|
||||||
|
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
|
||||||
|
|
||||||
'@types/estree-jsx@1.0.5':
|
'@types/estree-jsx@1.0.5':
|
||||||
resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==}
|
resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==}
|
||||||
|
|
||||||
@@ -1756,6 +1765,35 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
|
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
|
||||||
|
|
||||||
|
'@vitest/expect@3.2.4':
|
||||||
|
resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==}
|
||||||
|
|
||||||
|
'@vitest/mocker@3.2.4':
|
||||||
|
resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==}
|
||||||
|
peerDependencies:
|
||||||
|
msw: ^2.4.9
|
||||||
|
vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
msw:
|
||||||
|
optional: true
|
||||||
|
vite:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@vitest/pretty-format@3.2.4':
|
||||||
|
resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==}
|
||||||
|
|
||||||
|
'@vitest/runner@3.2.4':
|
||||||
|
resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==}
|
||||||
|
|
||||||
|
'@vitest/snapshot@3.2.4':
|
||||||
|
resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==}
|
||||||
|
|
||||||
|
'@vitest/spy@3.2.4':
|
||||||
|
resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==}
|
||||||
|
|
||||||
|
'@vitest/utils@3.2.4':
|
||||||
|
resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==}
|
||||||
|
|
||||||
abstract-logging@2.0.1:
|
abstract-logging@2.0.1:
|
||||||
resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==}
|
resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==}
|
||||||
|
|
||||||
@@ -1809,6 +1847,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
|
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
assertion-error@2.0.1:
|
||||||
|
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
ast-types@0.16.1:
|
ast-types@0.16.1:
|
||||||
resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==}
|
resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
@@ -1863,6 +1905,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
|
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
|
|
||||||
|
cac@6.7.14:
|
||||||
|
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
call-bind-apply-helpers@1.0.2:
|
call-bind-apply-helpers@1.0.2:
|
||||||
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
|
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -1881,6 +1927,10 @@ packages:
|
|||||||
ccount@2.0.1:
|
ccount@2.0.1:
|
||||||
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
|
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
|
||||||
|
|
||||||
|
chai@5.3.3:
|
||||||
|
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
chalk@5.6.2:
|
chalk@5.6.2:
|
||||||
resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==}
|
resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==}
|
||||||
engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
|
engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
|
||||||
@@ -1897,6 +1947,10 @@ packages:
|
|||||||
character-reference-invalid@2.0.1:
|
character-reference-invalid@2.0.1:
|
||||||
resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==}
|
resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==}
|
||||||
|
|
||||||
|
check-error@2.1.3:
|
||||||
|
resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
|
||||||
|
engines: {node: '>= 16'}
|
||||||
|
|
||||||
class-variance-authority@0.7.1:
|
class-variance-authority@0.7.1:
|
||||||
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
|
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
|
||||||
|
|
||||||
@@ -2021,6 +2075,10 @@ packages:
|
|||||||
babel-plugin-macros:
|
babel-plugin-macros:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
deep-eql@5.0.2:
|
||||||
|
resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
deepmerge@4.3.1:
|
deepmerge@4.3.1:
|
||||||
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
|
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -2121,6 +2179,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
|
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
es-module-lexer@1.7.0:
|
||||||
|
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
|
||||||
|
|
||||||
es-object-atoms@1.1.1:
|
es-object-atoms@1.1.1:
|
||||||
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
|
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -2154,6 +2215,9 @@ packages:
|
|||||||
estree-util-is-identifier-name@3.0.0:
|
estree-util-is-identifier-name@3.0.0:
|
||||||
resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==}
|
resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==}
|
||||||
|
|
||||||
|
estree-walker@3.0.3:
|
||||||
|
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
|
||||||
|
|
||||||
etag@1.8.1:
|
etag@1.8.1:
|
||||||
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
|
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
@@ -2174,6 +2238,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==}
|
resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==}
|
||||||
engines: {node: ^18.19.0 || >=20.5.0}
|
engines: {node: ^18.19.0 || >=20.5.0}
|
||||||
|
|
||||||
|
expect-type@1.3.0:
|
||||||
|
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
|
||||||
|
engines: {node: '>=12.0.0'}
|
||||||
|
|
||||||
express-rate-limit@8.5.2:
|
express-rate-limit@8.5.2:
|
||||||
resolution: {integrity: sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==}
|
resolution: {integrity: sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==}
|
||||||
engines: {node: '>= 16'}
|
engines: {node: '>= 16'}
|
||||||
@@ -2529,6 +2597,9 @@ packages:
|
|||||||
js-tokens@4.0.0:
|
js-tokens@4.0.0:
|
||||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||||
|
|
||||||
|
js-tokens@9.0.1:
|
||||||
|
resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==}
|
||||||
|
|
||||||
js-yaml@4.1.1:
|
js-yaml@4.1.1:
|
||||||
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
|
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -2653,6 +2724,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
|
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
loupe@3.2.1:
|
||||||
|
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
|
||||||
|
|
||||||
lru-cache@10.4.3:
|
lru-cache@10.4.3:
|
||||||
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
|
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
|
||||||
|
|
||||||
@@ -2998,6 +3072,13 @@ packages:
|
|||||||
path-to-regexp@8.4.2:
|
path-to-regexp@8.4.2:
|
||||||
resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==}
|
resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==}
|
||||||
|
|
||||||
|
pathe@2.0.3:
|
||||||
|
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
||||||
|
|
||||||
|
pathval@2.0.1:
|
||||||
|
resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==}
|
||||||
|
engines: {node: '>= 14.16'}
|
||||||
|
|
||||||
picocolors@1.1.1:
|
picocolors@1.1.1:
|
||||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||||
|
|
||||||
@@ -3308,6 +3389,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
|
resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
siginfo@2.0.0:
|
||||||
|
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
|
||||||
|
|
||||||
signal-exit@3.0.7:
|
signal-exit@3.0.7:
|
||||||
resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==}
|
resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==}
|
||||||
|
|
||||||
@@ -3342,6 +3426,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
|
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
|
||||||
engines: {node: '>= 10.x'}
|
engines: {node: '>= 10.x'}
|
||||||
|
|
||||||
|
stackback@0.0.2:
|
||||||
|
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
||||||
|
|
||||||
statuses@2.0.1:
|
statuses@2.0.1:
|
||||||
resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}
|
resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@@ -3350,6 +3437,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
|
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
|
|
||||||
|
std-env@3.10.0:
|
||||||
|
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
|
||||||
|
|
||||||
stdin-discarder@0.2.2:
|
stdin-discarder@0.2.2:
|
||||||
resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==}
|
resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -3402,6 +3492,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==}
|
resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
strip-literal@3.1.0:
|
||||||
|
resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
|
||||||
|
|
||||||
style-to-js@1.1.21:
|
style-to-js@1.1.21:
|
||||||
resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==}
|
resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==}
|
||||||
|
|
||||||
@@ -3428,6 +3521,28 @@ packages:
|
|||||||
tiny-invariant@1.3.3:
|
tiny-invariant@1.3.3:
|
||||||
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
|
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
|
||||||
|
|
||||||
|
tinybench@2.9.0:
|
||||||
|
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
||||||
|
|
||||||
|
tinyexec@0.3.2:
|
||||||
|
resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
|
||||||
|
|
||||||
|
tinyglobby@0.2.16:
|
||||||
|
resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
|
||||||
|
engines: {node: '>=12.0.0'}
|
||||||
|
|
||||||
|
tinypool@1.1.1:
|
||||||
|
resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==}
|
||||||
|
engines: {node: ^18.0.0 || >=20.0.0}
|
||||||
|
|
||||||
|
tinyrainbow@2.0.0:
|
||||||
|
resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==}
|
||||||
|
engines: {node: '>=14.0.0'}
|
||||||
|
|
||||||
|
tinyspy@4.0.4:
|
||||||
|
resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==}
|
||||||
|
engines: {node: '>=14.0.0'}
|
||||||
|
|
||||||
tldts-core@7.0.30:
|
tldts-core@7.0.30:
|
||||||
resolution: {integrity: sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==}
|
resolution: {integrity: sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==}
|
||||||
|
|
||||||
@@ -3572,6 +3687,11 @@ packages:
|
|||||||
vfile@6.0.3:
|
vfile@6.0.3:
|
||||||
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
|
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
|
||||||
|
|
||||||
|
vite-node@3.2.4:
|
||||||
|
resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==}
|
||||||
|
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
vite@5.4.21:
|
vite@5.4.21:
|
||||||
resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==}
|
resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==}
|
||||||
engines: {node: ^18.0.0 || >=20.0.0}
|
engines: {node: ^18.0.0 || >=20.0.0}
|
||||||
@@ -3603,6 +3723,34 @@ packages:
|
|||||||
terser:
|
terser:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
vitest@3.2.4:
|
||||||
|
resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==}
|
||||||
|
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
|
||||||
|
hasBin: true
|
||||||
|
peerDependencies:
|
||||||
|
'@edge-runtime/vm': '*'
|
||||||
|
'@types/debug': ^4.1.12
|
||||||
|
'@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0
|
||||||
|
'@vitest/browser': 3.2.4
|
||||||
|
'@vitest/ui': 3.2.4
|
||||||
|
happy-dom: '*'
|
||||||
|
jsdom: '*'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@edge-runtime/vm':
|
||||||
|
optional: true
|
||||||
|
'@types/debug':
|
||||||
|
optional: true
|
||||||
|
'@types/node':
|
||||||
|
optional: true
|
||||||
|
'@vitest/browser':
|
||||||
|
optional: true
|
||||||
|
'@vitest/ui':
|
||||||
|
optional: true
|
||||||
|
happy-dom:
|
||||||
|
optional: true
|
||||||
|
jsdom:
|
||||||
|
optional: true
|
||||||
|
|
||||||
web-streams-polyfill@3.3.3:
|
web-streams-polyfill@3.3.3:
|
||||||
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
|
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
@@ -3617,6 +3765,11 @@ packages:
|
|||||||
engines: {node: ^16.13.0 || >=18.0.0}
|
engines: {node: ^16.13.0 || >=18.0.0}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
why-is-node-running@2.3.0:
|
||||||
|
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
wrap-ansi@7.0.0:
|
wrap-ansi@7.0.0:
|
||||||
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
|
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -5196,10 +5349,17 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@babel/types': 7.29.0
|
'@babel/types': 7.29.0
|
||||||
|
|
||||||
|
'@types/chai@5.2.3':
|
||||||
|
dependencies:
|
||||||
|
'@types/deep-eql': 4.0.2
|
||||||
|
assertion-error: 2.0.1
|
||||||
|
|
||||||
'@types/debug@4.1.13':
|
'@types/debug@4.1.13':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/ms': 2.1.0
|
'@types/ms': 2.1.0
|
||||||
|
|
||||||
|
'@types/deep-eql@4.0.2': {}
|
||||||
|
|
||||||
'@types/estree-jsx@1.0.5':
|
'@types/estree-jsx@1.0.5':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/estree': 1.0.8
|
'@types/estree': 1.0.8
|
||||||
@@ -5261,6 +5421,49 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
'@vitest/expect@3.2.4':
|
||||||
|
dependencies:
|
||||||
|
'@types/chai': 5.2.3
|
||||||
|
'@vitest/spy': 3.2.4
|
||||||
|
'@vitest/utils': 3.2.4
|
||||||
|
chai: 5.3.3
|
||||||
|
tinyrainbow: 2.0.0
|
||||||
|
|
||||||
|
'@vitest/mocker@3.2.4(msw@2.14.6(@types/node@20.19.41)(typescript@5.9.3))(vite@5.4.21(@types/node@20.19.41)(lightningcss@1.32.0))':
|
||||||
|
dependencies:
|
||||||
|
'@vitest/spy': 3.2.4
|
||||||
|
estree-walker: 3.0.3
|
||||||
|
magic-string: 0.30.21
|
||||||
|
optionalDependencies:
|
||||||
|
msw: 2.14.6(@types/node@20.19.41)(typescript@5.9.3)
|
||||||
|
vite: 5.4.21(@types/node@20.19.41)(lightningcss@1.32.0)
|
||||||
|
|
||||||
|
'@vitest/pretty-format@3.2.4':
|
||||||
|
dependencies:
|
||||||
|
tinyrainbow: 2.0.0
|
||||||
|
|
||||||
|
'@vitest/runner@3.2.4':
|
||||||
|
dependencies:
|
||||||
|
'@vitest/utils': 3.2.4
|
||||||
|
pathe: 2.0.3
|
||||||
|
strip-literal: 3.1.0
|
||||||
|
|
||||||
|
'@vitest/snapshot@3.2.4':
|
||||||
|
dependencies:
|
||||||
|
'@vitest/pretty-format': 3.2.4
|
||||||
|
magic-string: 0.30.21
|
||||||
|
pathe: 2.0.3
|
||||||
|
|
||||||
|
'@vitest/spy@3.2.4':
|
||||||
|
dependencies:
|
||||||
|
tinyspy: 4.0.4
|
||||||
|
|
||||||
|
'@vitest/utils@3.2.4':
|
||||||
|
dependencies:
|
||||||
|
'@vitest/pretty-format': 3.2.4
|
||||||
|
loupe: 3.2.1
|
||||||
|
tinyrainbow: 2.0.0
|
||||||
|
|
||||||
abstract-logging@2.0.1: {}
|
abstract-logging@2.0.1: {}
|
||||||
|
|
||||||
accepts@2.0.0:
|
accepts@2.0.0:
|
||||||
@@ -5301,6 +5504,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
assertion-error@2.0.1: {}
|
||||||
|
|
||||||
ast-types@0.16.1:
|
ast-types@0.16.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
@@ -5360,6 +5565,8 @@ snapshots:
|
|||||||
|
|
||||||
bytes@3.1.2: {}
|
bytes@3.1.2: {}
|
||||||
|
|
||||||
|
cac@6.7.14: {}
|
||||||
|
|
||||||
call-bind-apply-helpers@1.0.2:
|
call-bind-apply-helpers@1.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
@@ -5376,6 +5583,14 @@ snapshots:
|
|||||||
|
|
||||||
ccount@2.0.1: {}
|
ccount@2.0.1: {}
|
||||||
|
|
||||||
|
chai@5.3.3:
|
||||||
|
dependencies:
|
||||||
|
assertion-error: 2.0.1
|
||||||
|
check-error: 2.1.3
|
||||||
|
deep-eql: 5.0.2
|
||||||
|
loupe: 3.2.1
|
||||||
|
pathval: 2.0.1
|
||||||
|
|
||||||
chalk@5.6.2: {}
|
chalk@5.6.2: {}
|
||||||
|
|
||||||
character-entities-html4@2.1.0: {}
|
character-entities-html4@2.1.0: {}
|
||||||
@@ -5386,6 +5601,8 @@ snapshots:
|
|||||||
|
|
||||||
character-reference-invalid@2.0.1: {}
|
character-reference-invalid@2.0.1: {}
|
||||||
|
|
||||||
|
check-error@2.1.3: {}
|
||||||
|
|
||||||
class-variance-authority@0.7.1:
|
class-variance-authority@0.7.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
clsx: 2.1.1
|
clsx: 2.1.1
|
||||||
@@ -5474,6 +5691,8 @@ snapshots:
|
|||||||
|
|
||||||
dedent@1.7.2: {}
|
dedent@1.7.2: {}
|
||||||
|
|
||||||
|
deep-eql@5.0.2: {}
|
||||||
|
|
||||||
deepmerge@4.3.1: {}
|
deepmerge@4.3.1: {}
|
||||||
|
|
||||||
default-browser-id@5.0.1: {}
|
default-browser-id@5.0.1: {}
|
||||||
@@ -5556,6 +5775,8 @@ snapshots:
|
|||||||
|
|
||||||
es-errors@1.3.0: {}
|
es-errors@1.3.0: {}
|
||||||
|
|
||||||
|
es-module-lexer@1.7.0: {}
|
||||||
|
|
||||||
es-object-atoms@1.1.1:
|
es-object-atoms@1.1.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
@@ -5625,6 +5846,10 @@ snapshots:
|
|||||||
|
|
||||||
estree-util-is-identifier-name@3.0.0: {}
|
estree-util-is-identifier-name@3.0.0: {}
|
||||||
|
|
||||||
|
estree-walker@3.0.3:
|
||||||
|
dependencies:
|
||||||
|
'@types/estree': 1.0.8
|
||||||
|
|
||||||
etag@1.8.1: {}
|
etag@1.8.1: {}
|
||||||
|
|
||||||
eventsource-parser@3.0.8: {}
|
eventsource-parser@3.0.8: {}
|
||||||
@@ -5660,6 +5885,8 @@ snapshots:
|
|||||||
strip-final-newline: 4.0.0
|
strip-final-newline: 4.0.0
|
||||||
yoctocolors: 2.1.2
|
yoctocolors: 2.1.2
|
||||||
|
|
||||||
|
expect-type@1.3.0: {}
|
||||||
|
|
||||||
express-rate-limit@8.5.2(express@5.2.1):
|
express-rate-limit@8.5.2(express@5.2.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
express: 5.2.1
|
express: 5.2.1
|
||||||
@@ -6053,6 +6280,8 @@ snapshots:
|
|||||||
|
|
||||||
js-tokens@4.0.0: {}
|
js-tokens@4.0.0: {}
|
||||||
|
|
||||||
|
js-tokens@9.0.1: {}
|
||||||
|
|
||||||
js-yaml@4.1.1:
|
js-yaml@4.1.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
argparse: 2.0.1
|
argparse: 2.0.1
|
||||||
@@ -6149,6 +6378,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
js-tokens: 4.0.0
|
js-tokens: 4.0.0
|
||||||
|
|
||||||
|
loupe@3.2.1: {}
|
||||||
|
|
||||||
lru-cache@10.4.3: {}
|
lru-cache@10.4.3: {}
|
||||||
|
|
||||||
lru-cache@5.1.1:
|
lru-cache@5.1.1:
|
||||||
@@ -6700,6 +6931,10 @@ snapshots:
|
|||||||
|
|
||||||
path-to-regexp@8.4.2: {}
|
path-to-regexp@8.4.2: {}
|
||||||
|
|
||||||
|
pathe@2.0.3: {}
|
||||||
|
|
||||||
|
pathval@2.0.1: {}
|
||||||
|
|
||||||
picocolors@1.1.1: {}
|
picocolors@1.1.1: {}
|
||||||
|
|
||||||
picomatch@2.3.2: {}
|
picomatch@2.3.2: {}
|
||||||
@@ -7178,6 +7413,8 @@ snapshots:
|
|||||||
side-channel-map: 1.0.1
|
side-channel-map: 1.0.1
|
||||||
side-channel-weakmap: 1.0.2
|
side-channel-weakmap: 1.0.2
|
||||||
|
|
||||||
|
siginfo@2.0.0: {}
|
||||||
|
|
||||||
signal-exit@3.0.7: {}
|
signal-exit@3.0.7: {}
|
||||||
|
|
||||||
signal-exit@4.1.0: {}
|
signal-exit@4.1.0: {}
|
||||||
@@ -7201,10 +7438,14 @@ snapshots:
|
|||||||
|
|
||||||
split2@4.2.0: {}
|
split2@4.2.0: {}
|
||||||
|
|
||||||
|
stackback@0.0.2: {}
|
||||||
|
|
||||||
statuses@2.0.1: {}
|
statuses@2.0.1: {}
|
||||||
|
|
||||||
statuses@2.0.2: {}
|
statuses@2.0.2: {}
|
||||||
|
|
||||||
|
std-env@3.10.0: {}
|
||||||
|
|
||||||
stdin-discarder@0.2.2: {}
|
stdin-discarder@0.2.2: {}
|
||||||
|
|
||||||
stream-shift@1.0.3: {}
|
stream-shift@1.0.3: {}
|
||||||
@@ -7258,6 +7499,10 @@ snapshots:
|
|||||||
|
|
||||||
strip-final-newline@4.0.0: {}
|
strip-final-newline@4.0.0: {}
|
||||||
|
|
||||||
|
strip-literal@3.1.0:
|
||||||
|
dependencies:
|
||||||
|
js-tokens: 9.0.1
|
||||||
|
|
||||||
style-to-js@1.1.21:
|
style-to-js@1.1.21:
|
||||||
dependencies:
|
dependencies:
|
||||||
style-to-object: 1.0.14
|
style-to-object: 1.0.14
|
||||||
@@ -7280,6 +7525,21 @@ snapshots:
|
|||||||
|
|
||||||
tiny-invariant@1.3.3: {}
|
tiny-invariant@1.3.3: {}
|
||||||
|
|
||||||
|
tinybench@2.9.0: {}
|
||||||
|
|
||||||
|
tinyexec@0.3.2: {}
|
||||||
|
|
||||||
|
tinyglobby@0.2.16:
|
||||||
|
dependencies:
|
||||||
|
fdir: 6.5.0(picomatch@4.0.4)
|
||||||
|
picomatch: 4.0.4
|
||||||
|
|
||||||
|
tinypool@1.1.1: {}
|
||||||
|
|
||||||
|
tinyrainbow@2.0.0: {}
|
||||||
|
|
||||||
|
tinyspy@4.0.4: {}
|
||||||
|
|
||||||
tldts-core@7.0.30: {}
|
tldts-core@7.0.30: {}
|
||||||
|
|
||||||
tldts@7.0.30:
|
tldts@7.0.30:
|
||||||
@@ -7419,6 +7679,24 @@ snapshots:
|
|||||||
'@types/unist': 3.0.3
|
'@types/unist': 3.0.3
|
||||||
vfile-message: 4.0.3
|
vfile-message: 4.0.3
|
||||||
|
|
||||||
|
vite-node@3.2.4(@types/node@20.19.41)(lightningcss@1.32.0):
|
||||||
|
dependencies:
|
||||||
|
cac: 6.7.14
|
||||||
|
debug: 4.4.3
|
||||||
|
es-module-lexer: 1.7.0
|
||||||
|
pathe: 2.0.3
|
||||||
|
vite: 5.4.21(@types/node@20.19.41)(lightningcss@1.32.0)
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@types/node'
|
||||||
|
- less
|
||||||
|
- lightningcss
|
||||||
|
- sass
|
||||||
|
- sass-embedded
|
||||||
|
- stylus
|
||||||
|
- sugarss
|
||||||
|
- supports-color
|
||||||
|
- terser
|
||||||
|
|
||||||
vite@5.4.21(@types/node@20.19.41)(lightningcss@1.32.0):
|
vite@5.4.21(@types/node@20.19.41)(lightningcss@1.32.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
esbuild: 0.21.5
|
esbuild: 0.21.5
|
||||||
@@ -7429,6 +7707,45 @@ snapshots:
|
|||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
lightningcss: 1.32.0
|
lightningcss: 1.32.0
|
||||||
|
|
||||||
|
vitest@3.2.4(@types/debug@4.1.13)(@types/node@20.19.41)(lightningcss@1.32.0)(msw@2.14.6(@types/node@20.19.41)(typescript@5.9.3)):
|
||||||
|
dependencies:
|
||||||
|
'@types/chai': 5.2.3
|
||||||
|
'@vitest/expect': 3.2.4
|
||||||
|
'@vitest/mocker': 3.2.4(msw@2.14.6(@types/node@20.19.41)(typescript@5.9.3))(vite@5.4.21(@types/node@20.19.41)(lightningcss@1.32.0))
|
||||||
|
'@vitest/pretty-format': 3.2.4
|
||||||
|
'@vitest/runner': 3.2.4
|
||||||
|
'@vitest/snapshot': 3.2.4
|
||||||
|
'@vitest/spy': 3.2.4
|
||||||
|
'@vitest/utils': 3.2.4
|
||||||
|
chai: 5.3.3
|
||||||
|
debug: 4.4.3
|
||||||
|
expect-type: 1.3.0
|
||||||
|
magic-string: 0.30.21
|
||||||
|
pathe: 2.0.3
|
||||||
|
picomatch: 4.0.4
|
||||||
|
std-env: 3.10.0
|
||||||
|
tinybench: 2.9.0
|
||||||
|
tinyexec: 0.3.2
|
||||||
|
tinyglobby: 0.2.16
|
||||||
|
tinypool: 1.1.1
|
||||||
|
tinyrainbow: 2.0.0
|
||||||
|
vite: 5.4.21(@types/node@20.19.41)(lightningcss@1.32.0)
|
||||||
|
vite-node: 3.2.4(@types/node@20.19.41)(lightningcss@1.32.0)
|
||||||
|
why-is-node-running: 2.3.0
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/debug': 4.1.13
|
||||||
|
'@types/node': 20.19.41
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- less
|
||||||
|
- lightningcss
|
||||||
|
- msw
|
||||||
|
- sass
|
||||||
|
- sass-embedded
|
||||||
|
- stylus
|
||||||
|
- sugarss
|
||||||
|
- supports-color
|
||||||
|
- terser
|
||||||
|
|
||||||
web-streams-polyfill@3.3.3: {}
|
web-streams-polyfill@3.3.3: {}
|
||||||
|
|
||||||
which@2.0.2:
|
which@2.0.2:
|
||||||
@@ -7439,6 +7756,11 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
isexe: 3.1.5
|
isexe: 3.1.5
|
||||||
|
|
||||||
|
why-is-node-running@2.3.0:
|
||||||
|
dependencies:
|
||||||
|
siginfo: 2.0.0
|
||||||
|
stackback: 0.0.2
|
||||||
|
|
||||||
wrap-ansi@7.0.0:
|
wrap-ansi@7.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
ansi-styles: 4.3.0
|
ansi-styles: 4.3.0
|
||||||
|
|||||||
Reference in New Issue
Block a user