commit
0e4496cb1b
12 changed files with 1361 additions and 188 deletions
|
|
@ -55,6 +55,7 @@ TODO: add instructions
|
|||
- [x] kbd powered popover model picker
|
||||
- [x] autosize
|
||||
- [x] AbortController for message generation
|
||||
- [ ] Per route msg persistance
|
||||
|
||||
### Extra
|
||||
|
||||
|
|
|
|||
|
|
@ -78,7 +78,6 @@
|
|||
"@fontsource-variable/nunito-sans": "^5.2.6",
|
||||
"@fontsource-variable/open-sans": "^5.2.6",
|
||||
"better-auth": "^1.2.9",
|
||||
"bits-ui": "^2.8.0",
|
||||
"convex-helpers": "^0.1.94",
|
||||
"markdown-it-async": "^2.2.0",
|
||||
"openai": "^5.3.0",
|
||||
|
|
|
|||
54
pnpm-lock.yaml
generated
54
pnpm-lock.yaml
generated
|
|
@ -35,9 +35,6 @@ importers:
|
|||
better-auth:
|
||||
specifier: ^1.2.9
|
||||
version: 1.2.9
|
||||
bits-ui:
|
||||
specifier: ^2.8.0
|
||||
version: 2.8.0(@internationalized/date@3.8.2)(svelte@5.34.1)
|
||||
convex-helpers:
|
||||
specifier: ^0.1.94
|
||||
version: 0.1.94(convex@1.24.8)(typescript@5.8.3)(zod@3.25.64)
|
||||
|
|
@ -668,9 +665,6 @@ packages:
|
|||
'@iconify/utils@2.3.0':
|
||||
resolution: {integrity: sha512-GmQ78prtwYW6EtzXRU1rY+KwOKfz32PD7iJh6Iyqw68GiKuoZ2A6pRtzWONz5VQJbp50mEjXh/7NkumtrAgRKA==}
|
||||
|
||||
'@internationalized/date@3.8.2':
|
||||
resolution: {integrity: sha512-/wENk7CbvLbkUvX1tu0mwq49CVkkWpkXubGel6birjRPyo6uQ4nQpnq5xZu823zRCwwn82zgHrvgF1vZyvmVgA==}
|
||||
|
||||
'@isaacs/fs-minipass@4.0.1':
|
||||
resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
|
@ -915,9 +909,6 @@ packages:
|
|||
svelte: ^5.0.0
|
||||
vite: ^6.0.0
|
||||
|
||||
'@swc/helpers@0.5.17':
|
||||
resolution: {integrity: sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==}
|
||||
|
||||
'@tailwindcss/node@4.1.10':
|
||||
resolution: {integrity: sha512-2ACf1znY5fpRBwRhMgj9ZXvb2XZW8qs+oTfotJ2C5xR0/WNL7UHZ7zXl6s+rUqedL1mNi+0O+WQr5awGowS3PQ==}
|
||||
|
||||
|
|
@ -1238,13 +1229,6 @@ packages:
|
|||
better-call@1.0.9:
|
||||
resolution: {integrity: sha512-Qfm0gjk0XQz0oI7qvTK1hbqTsBY4xV2hsHAxF8LZfUYl3RaECCIifXuVqtPpZJWvlCCMlQSvkvhhyuApGUba6g==}
|
||||
|
||||
bits-ui@2.8.0:
|
||||
resolution: {integrity: sha512-WiTZcCbYLm4Cx6/67NqXVSD0BkfNmdX8Abs84HpIaplX/wRRbg8tkMtJYlLw7mepgGvwGR3enLi6tFkcHU3JXA==}
|
||||
engines: {node: '>=20', pnpm: '>=8.7.0'}
|
||||
peerDependencies:
|
||||
'@internationalized/date': ^3.8.1
|
||||
svelte: ^5.33.0
|
||||
|
||||
brace-expansion@1.1.12:
|
||||
resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
|
||||
|
||||
|
|
@ -2410,12 +2394,6 @@ packages:
|
|||
peerDependencies:
|
||||
svelte: ^5.0.0
|
||||
|
||||
svelte-toolbelt@0.9.2:
|
||||
resolution: {integrity: sha512-REb1cENGnFbhNSmIdCb1SDIpjEa3n1kXhNVHqGNEesjmPX3bG87gUZiCG8cqOt9AAarqzTzOtI2jEEWr/ZbHwA==}
|
||||
engines: {node: '>=18', pnpm: '>=8.7.0'}
|
||||
peerDependencies:
|
||||
svelte: ^5.30.2
|
||||
|
||||
svelte@5.34.1:
|
||||
resolution: {integrity: sha512-jWNnN2hZFNtnzKPptCcJHBWrD9CtbHPDwIRIODufOYaWkR0kLmAIlM384lMt4ucwuIRX4hCJwD2D8ZtEcGJQ0Q==}
|
||||
engines: {node: '>=18'}
|
||||
|
|
@ -2423,9 +2401,6 @@ packages:
|
|||
symbol-tree@3.2.4:
|
||||
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
|
||||
|
||||
tabbable@6.2.0:
|
||||
resolution: {integrity: sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==}
|
||||
|
||||
tailwind-merge@3.0.2:
|
||||
resolution: {integrity: sha512-l7z+OYZ7mu3DTqrL88RiKrKIqO3NcpEO8V/Od04bNpvk0kiIFndGEoqfuzvj4yuhRkHKjRkII2z+KS2HfPcSxw==}
|
||||
|
||||
|
|
@ -3099,10 +3074,6 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@internationalized/date@3.8.2':
|
||||
dependencies:
|
||||
'@swc/helpers': 0.5.17
|
||||
|
||||
'@isaacs/fs-minipass@4.0.1':
|
||||
dependencies:
|
||||
minipass: 7.1.2
|
||||
|
|
@ -3348,10 +3319,6 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@swc/helpers@0.5.17':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@tailwindcss/node@4.1.10':
|
||||
dependencies:
|
||||
'@ampproject/remapping': 2.3.0
|
||||
|
|
@ -3700,18 +3667,6 @@ snapshots:
|
|||
set-cookie-parser: 2.7.1
|
||||
uncrypto: 0.1.3
|
||||
|
||||
bits-ui@2.8.0(@internationalized/date@3.8.2)(svelte@5.34.1):
|
||||
dependencies:
|
||||
'@floating-ui/core': 1.7.1
|
||||
'@floating-ui/dom': 1.7.1
|
||||
'@internationalized/date': 3.8.2
|
||||
css.escape: 1.5.1
|
||||
esm-env: 1.2.2
|
||||
runed: 0.28.0(svelte@5.34.1)
|
||||
svelte: 5.34.1
|
||||
svelte-toolbelt: 0.9.2(svelte@5.34.1)
|
||||
tabbable: 6.2.0
|
||||
|
||||
brace-expansion@1.1.12:
|
||||
dependencies:
|
||||
balanced-match: 1.0.2
|
||||
|
|
@ -4819,13 +4774,6 @@ snapshots:
|
|||
style-to-object: 1.0.9
|
||||
svelte: 5.34.1
|
||||
|
||||
svelte-toolbelt@0.9.2(svelte@5.34.1):
|
||||
dependencies:
|
||||
clsx: 2.1.1
|
||||
runed: 0.28.0(svelte@5.34.1)
|
||||
style-to-object: 1.0.9
|
||||
svelte: 5.34.1
|
||||
|
||||
svelte@5.34.1:
|
||||
dependencies:
|
||||
'@ampproject/remapping': 2.3.0
|
||||
|
|
@ -4845,8 +4793,6 @@ snapshots:
|
|||
|
||||
symbol-tree@3.2.4: {}
|
||||
|
||||
tabbable@6.2.0: {}
|
||||
|
||||
tailwind-merge@3.0.2: {}
|
||||
|
||||
tailwind-merge@3.3.1: {}
|
||||
|
|
|
|||
205
src/lib/builders/grid-command.svelte.ts
Normal file
205
src/lib/builders/grid-command.svelte.ts
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
import { getNextMatrixItem, type Direction } from '$lib/utils/array';
|
||||
import { dataAttr } from '$lib/utils/attribute';
|
||||
import type { MaybeGetter } from 'melt';
|
||||
import { extract } from 'runed';
|
||||
import { tick } from 'svelte';
|
||||
import { createAttachmentKey } from 'svelte/attachments';
|
||||
import type { HTMLAttributes, HTMLInputAttributes } from 'svelte/elements';
|
||||
|
||||
export type GridCommandProps = {
|
||||
columns: MaybeGetter<number>;
|
||||
onSelect: (value: string) => void;
|
||||
};
|
||||
export class GridCommand {
|
||||
/* State */
|
||||
rootEl: HTMLElement | null = null;
|
||||
highlighted = $state<string>();
|
||||
inputValue = $state('');
|
||||
|
||||
/* Props */
|
||||
columns: number;
|
||||
onSelect: (value: string) => void;
|
||||
|
||||
constructor(props: GridCommandProps) {
|
||||
this.columns = $derived(extract(props.columns));
|
||||
this.onSelect = props.onSelect;
|
||||
}
|
||||
|
||||
get root() {
|
||||
return {
|
||||
[createAttachmentKey()]: (node) => {
|
||||
this.rootEl = node;
|
||||
return () => {
|
||||
if (this.rootEl !== node) return;
|
||||
this.rootEl = null;
|
||||
};
|
||||
},
|
||||
} as const satisfies HTMLAttributes<HTMLElement>;
|
||||
}
|
||||
|
||||
get group() {
|
||||
return {
|
||||
'data-thom-grid-command-group': '',
|
||||
} as const satisfies HTMLAttributes<HTMLElement>;
|
||||
}
|
||||
|
||||
get groupHeading() {
|
||||
return {
|
||||
'data-thom-grid-command-group-heading': '',
|
||||
} as const satisfies HTMLAttributes<HTMLElement>;
|
||||
}
|
||||
|
||||
get input() {
|
||||
return {
|
||||
value: this.inputValue,
|
||||
oninput: async (e) => {
|
||||
this.inputValue = e.currentTarget.value;
|
||||
await tick();
|
||||
const items = this.getItems();
|
||||
const highlightedEl = items.find((item) => item.dataset.value === this.highlighted);
|
||||
if (!highlightedEl) {
|
||||
const firstItem = items[0];
|
||||
if (!firstItem) this.highlighted = undefined;
|
||||
else this.highlighted = firstItem.dataset.value;
|
||||
}
|
||||
},
|
||||
onkeydown: (e) => {
|
||||
const rows = this.getRows();
|
||||
if (rows.length === 0) return;
|
||||
|
||||
const row = rows.findIndex((row) => {
|
||||
return !!row.find((item) => {
|
||||
return this.highlighted === item.dataset.value;
|
||||
});
|
||||
});
|
||||
|
||||
if (row === -1) return;
|
||||
const col = rows[row]!.findIndex((item) => this.highlighted === item.dataset.value);
|
||||
|
||||
const dirMap: Record<string, Direction> = {
|
||||
ArrowUp: 'up',
|
||||
ArrowDown: 'down',
|
||||
ArrowLeft: 'left',
|
||||
ArrowRight: 'right',
|
||||
};
|
||||
const dir = dirMap[e.key];
|
||||
if (dir) {
|
||||
const next = getNextMatrixItem({
|
||||
matrix: rows,
|
||||
currentRow: row,
|
||||
currentCol: col,
|
||||
direction: dir,
|
||||
isAvailable: (item) => item.dataset.disabled === undefined,
|
||||
});
|
||||
if (next) {
|
||||
this.highlighted = next.dataset.value;
|
||||
this.scrollToHighlighted();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Enter' && this.highlighted) {
|
||||
e.preventDefault();
|
||||
this.onSelect(this.highlighted);
|
||||
}
|
||||
},
|
||||
} as const satisfies HTMLInputAttributes;
|
||||
}
|
||||
|
||||
scrollToHighlighted() {
|
||||
const groups = this.getGroups();
|
||||
const items = groups.flatMap((group) => group);
|
||||
const highlightedEl = items.find((item) => item.dataset.value === this.highlighted);
|
||||
if (!highlightedEl) return;
|
||||
|
||||
const nextGroupIdx = groups.findIndex((group) => group.includes(highlightedEl));
|
||||
const nextGroup = groups[nextGroupIdx]!;
|
||||
const nextItemIdx = nextGroup.findIndex((item) => item === highlightedEl);
|
||||
const nextItemRow = Math.floor(nextItemIdx / this.columns);
|
||||
if (nextItemRow === 0) {
|
||||
const nextGroupEl = Array.from(
|
||||
this.rootEl!.querySelectorAll('[data-thom-grid-command-group]')
|
||||
)[nextGroupIdx];
|
||||
const nextGroupHeadingEl = nextGroupEl?.querySelector(
|
||||
'[data-thom-grid-command-group-heading]'
|
||||
);
|
||||
if (nextGroupHeadingEl) {
|
||||
nextGroupHeadingEl.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}
|
||||
highlightedEl.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
|
||||
getRows() {
|
||||
const groups = this.getGroups();
|
||||
if (groups.length === 0) return [];
|
||||
|
||||
// split groups into rows
|
||||
const rows: Array<HTMLElement[]> = [];
|
||||
|
||||
for (let i = 0; i < groups.length; i++) {
|
||||
const items = [...groups[i]!];
|
||||
|
||||
let row: HTMLElement[] = [];
|
||||
while (items.length > 0) {
|
||||
const item = items.shift()!;
|
||||
row.push(item);
|
||||
if (row.length === this.columns) {
|
||||
rows.push(row);
|
||||
row = [];
|
||||
}
|
||||
}
|
||||
if (row.length > 0) {
|
||||
rows.push(row);
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
getGroups() {
|
||||
if (!this.rootEl) return [];
|
||||
|
||||
const groups = Array.from(this.rootEl.querySelectorAll('[data-thom-grid-command-group]'));
|
||||
|
||||
const result: Array<HTMLElement[]> = [];
|
||||
|
||||
for (const group of groups) {
|
||||
const groupItems = Array.from(
|
||||
group.querySelectorAll('[data-thom-grid-command-item]')
|
||||
) as HTMLElement[];
|
||||
|
||||
result.push(groupItems);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
getItems() {
|
||||
return this.getRows()
|
||||
.flatMap((row) => row)
|
||||
.filter((item) => !item.dataset.disabled);
|
||||
}
|
||||
|
||||
getItem(value: string, args: { disabled?: boolean } = {}) {
|
||||
return {
|
||||
'data-thom-grid-command-item': '',
|
||||
'data-highlighted': dataAttr(value === this.highlighted),
|
||||
'data-value': dataAttr(value),
|
||||
'data-disabled': dataAttr(args?.disabled),
|
||||
onmouseover: () => {
|
||||
if (args?.disabled) return;
|
||||
this.highlighted = value;
|
||||
},
|
||||
onclick: () => {
|
||||
if (args?.disabled) return;
|
||||
this.onSelect(value);
|
||||
},
|
||||
[createAttachmentKey()]: () => {
|
||||
if (!this.highlighted) {
|
||||
this.highlighted = value;
|
||||
}
|
||||
},
|
||||
} as const satisfies HTMLAttributes<HTMLElement>;
|
||||
}
|
||||
}
|
||||
743
src/lib/utils/array.spec.ts
Normal file
743
src/lib/utils/array.spec.ts
Normal file
|
|
@ -0,0 +1,743 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { getNextMatrixItem } from './array';
|
||||
|
||||
// --- Test Data ---
|
||||
|
||||
// Simple matrix for basic tests
|
||||
const simpleMatrix = [
|
||||
[1, 2, 3],
|
||||
[4, 5, 6],
|
||||
[7, 8, 9],
|
||||
];
|
||||
|
||||
// Jagged matrix to test snapping behavior
|
||||
const jaggedMatrix = [
|
||||
[10, 11, 12], // Row 0 (length 3)
|
||||
[13, 14], // Row 1 (length 2)
|
||||
[15, 16, 17, 18], // Row 2 (length 4)
|
||||
[19], // Row 3 (length 1)
|
||||
[], // Row 4 (length 0)
|
||||
];
|
||||
|
||||
// Item interface for availability tests
|
||||
interface TestItem {
|
||||
id: number;
|
||||
available: boolean;
|
||||
}
|
||||
|
||||
// Matrix with availability for testing isAvailable predicate
|
||||
const matrixWithAvailability: TestItem[][] = [
|
||||
[
|
||||
{ id: 1, available: true },
|
||||
{ id: 2, available: false },
|
||||
{ id: 3, available: true },
|
||||
], // Row 0
|
||||
[
|
||||
{ id: 4, available: true },
|
||||
{ id: 5, available: false },
|
||||
], // Row 1
|
||||
[
|
||||
{ id: 6, available: true },
|
||||
{ id: 7, available: true },
|
||||
{ id: 8, available: false },
|
||||
{ id: 9, available: true },
|
||||
], // Row 2
|
||||
[{ id: 10, available: false }], // Row 3
|
||||
[
|
||||
{ id: 11, available: false },
|
||||
{ id: 12, available: false },
|
||||
], // Row 4 (all unavailable)
|
||||
[{ id: 13, available: true }], // Row 5
|
||||
];
|
||||
|
||||
// isAvailable predicate for TestItem
|
||||
const isTestItemAvailable = (item: TestItem) => item.available;
|
||||
|
||||
// --- Test Suite ---
|
||||
|
||||
describe('getNextMatrixItem', () => {
|
||||
// --- Input Validation Tests ---
|
||||
|
||||
describe('Input Validation', () => {
|
||||
it('should return undefined for empty matrix', () => {
|
||||
expect(
|
||||
getNextMatrixItem({ matrix: [], currentRow: 0, currentCol: 0, direction: 'down' })
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined for invalid currentRow (negative)', () => {
|
||||
expect(
|
||||
getNextMatrixItem({
|
||||
matrix: simpleMatrix,
|
||||
currentRow: -1,
|
||||
currentCol: 0,
|
||||
direction: 'down',
|
||||
})
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined for invalid currentRow (out of bounds)', () => {
|
||||
expect(
|
||||
getNextMatrixItem({
|
||||
matrix: simpleMatrix,
|
||||
currentRow: 99,
|
||||
currentCol: 0,
|
||||
direction: 'down',
|
||||
})
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined for invalid currentCol (negative)', () => {
|
||||
expect(
|
||||
getNextMatrixItem({
|
||||
matrix: simpleMatrix,
|
||||
currentRow: 0,
|
||||
currentCol: -1,
|
||||
direction: 'right',
|
||||
})
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined for invalid currentCol (out of bounds for current row)', () => {
|
||||
expect(
|
||||
getNextMatrixItem({
|
||||
matrix: simpleMatrix,
|
||||
currentRow: 0,
|
||||
currentCol: 99,
|
||||
direction: 'right',
|
||||
})
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// --- Basic Movement Tests (without isAvailable, no jagged snapping scenarios) ---
|
||||
|
||||
describe('Basic Movement (standard grid)', () => {
|
||||
it('should move right correctly', () => {
|
||||
expect(
|
||||
getNextMatrixItem({
|
||||
matrix: simpleMatrix,
|
||||
currentRow: 0,
|
||||
currentCol: 0,
|
||||
direction: 'right',
|
||||
})
|
||||
).toBe(2);
|
||||
});
|
||||
|
||||
it('should move left correctly', () => {
|
||||
expect(
|
||||
getNextMatrixItem({ matrix: simpleMatrix, currentRow: 0, currentCol: 2, direction: 'left' })
|
||||
).toBe(2);
|
||||
});
|
||||
|
||||
it('should move down correctly', () => {
|
||||
expect(
|
||||
getNextMatrixItem({ matrix: simpleMatrix, currentRow: 0, currentCol: 0, direction: 'down' })
|
||||
).toBe(4);
|
||||
});
|
||||
|
||||
it('should move up correctly', () => {
|
||||
expect(
|
||||
getNextMatrixItem({ matrix: simpleMatrix, currentRow: 2, currentCol: 0, direction: 'up' })
|
||||
).toBe(4);
|
||||
});
|
||||
|
||||
it('should return undefined when moving right off edge', () => {
|
||||
expect(
|
||||
getNextMatrixItem({
|
||||
matrix: simpleMatrix,
|
||||
currentRow: 0,
|
||||
currentCol: 2,
|
||||
direction: 'right',
|
||||
})
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined when moving left off edge', () => {
|
||||
expect(
|
||||
getNextMatrixItem({ matrix: simpleMatrix, currentRow: 0, currentCol: 0, direction: 'left' })
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined when moving down off edge', () => {
|
||||
expect(
|
||||
getNextMatrixItem({ matrix: simpleMatrix, currentRow: 2, currentCol: 0, direction: 'down' })
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined when moving up off edge', () => {
|
||||
expect(
|
||||
getNextMatrixItem({ matrix: simpleMatrix, currentRow: 0, currentCol: 0, direction: 'up' })
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// --- Jagged Array & Snapping Tests ---
|
||||
|
||||
describe('Jagged Array & Snapping', () => {
|
||||
it('should snap right when moving down to a shorter row', () => {
|
||||
// From (0,2) [12] -> down. Row 1 is [13, 14]. Column 2 is out of bounds for row 1.
|
||||
// Should snap to (1,1) [14]
|
||||
expect(
|
||||
getNextMatrixItem({ matrix: jaggedMatrix, currentRow: 0, currentCol: 2, direction: 'down' })
|
||||
).toBe(14);
|
||||
});
|
||||
|
||||
it('should snap right when moving up to a shorter row', () => {
|
||||
// From (2,3) [18] -> up. Row 1 is [13, 14]. Column 3 is out of bounds for row 1.
|
||||
// Should snap to (1,1) [14]
|
||||
expect(
|
||||
getNextMatrixItem({ matrix: jaggedMatrix, currentRow: 2, currentCol: 3, direction: 'up' })
|
||||
).toBe(14);
|
||||
});
|
||||
|
||||
it('should return undefined when snapping results in an invalid column (row is too short/empty)', () => {
|
||||
// From (0,0) [10] -> down. Row 4 is []. Column 0 is out of bounds.
|
||||
expect(
|
||||
getNextMatrixItem({ matrix: jaggedMatrix, currentRow: 0, currentCol: 0, direction: 'down' })
|
||||
).toBe(13); // First down
|
||||
expect(
|
||||
getNextMatrixItem({ matrix: jaggedMatrix, currentRow: 1, currentCol: 0, direction: 'down' })
|
||||
).toBe(15); // Second down
|
||||
expect(
|
||||
getNextMatrixItem({ matrix: jaggedMatrix, currentRow: 2, currentCol: 0, direction: 'down' })
|
||||
).toBe(19); // Third down
|
||||
expect(
|
||||
getNextMatrixItem({ matrix: jaggedMatrix, currentRow: 3, currentCol: 0, direction: 'down' })
|
||||
).toBeUndefined(); // Fourth down to empty row 4
|
||||
});
|
||||
|
||||
it('should not snap if target row is longer or same length', () => {
|
||||
// From (1,0) [13] -> down. Row 2 is [15,16,17,18]. Column 0 is valid.
|
||||
expect(
|
||||
getNextMatrixItem({ matrix: jaggedMatrix, currentRow: 1, currentCol: 0, direction: 'down' })
|
||||
).toBe(15);
|
||||
// From (1,1) [14] -> down. Row 2 is [15,16,17,18]. Column 1 is valid.
|
||||
expect(
|
||||
getNextMatrixItem({ matrix: jaggedMatrix, currentRow: 1, currentCol: 1, direction: 'down' })
|
||||
).toBe(16);
|
||||
});
|
||||
|
||||
it('should handle moving to a row with only one element when current column is greater', () => {
|
||||
// From (0,2) [12] -> down. Row 3 is [19]. Column 2 is out of bounds.
|
||||
// Should snap to (3,0) [19]
|
||||
expect(
|
||||
getNextMatrixItem({ matrix: jaggedMatrix, currentRow: 0, currentCol: 2, direction: 'down' })
|
||||
).toBe(14); // Down to row 1
|
||||
expect(
|
||||
getNextMatrixItem({ matrix: jaggedMatrix, currentRow: 1, currentCol: 1, direction: 'down' })
|
||||
).toBe(16); // Down to row 2
|
||||
expect(
|
||||
getNextMatrixItem({ matrix: jaggedMatrix, currentRow: 2, currentCol: 3, direction: 'down' })
|
||||
).toBe(19); // Down to row 3 (snaps from col 3 to col 0)
|
||||
});
|
||||
});
|
||||
|
||||
// --- Availability Tests ---
|
||||
|
||||
describe('Availability (isAvailable predicate)', () => {
|
||||
it('should return the item if it is available and no isAvailable is provided', () => {
|
||||
// Default behavior, all items are available
|
||||
expect(
|
||||
getNextMatrixItem({
|
||||
matrix: simpleMatrix,
|
||||
currentRow: 0,
|
||||
currentCol: 0,
|
||||
direction: 'right',
|
||||
})
|
||||
).toBe(2);
|
||||
});
|
||||
|
||||
it('should return the item if it is available and isAvailable returns true', () => {
|
||||
// (0,0) is {id:1, available:true}. Move right to (0,1) {id:2, available:false}
|
||||
// This test is for the *first* item, not skipping.
|
||||
expect(
|
||||
getNextMatrixItem({
|
||||
matrix: matrixWithAvailability,
|
||||
currentRow: 0,
|
||||
currentCol: 0,
|
||||
direction: 'down',
|
||||
isAvailable: isTestItemAvailable,
|
||||
})
|
||||
).toEqual({ id: 4, available: true });
|
||||
});
|
||||
|
||||
// --- Horizontal Scan (right) ---
|
||||
it('should scan right to find the next available item', () => {
|
||||
// From (0,0) {id:1, true} -> right. Next is (0,1) {id:2, false}. Scan right to (0,2) {id:3, true}.
|
||||
const result = getNextMatrixItem({
|
||||
matrix: matrixWithAvailability,
|
||||
currentRow: 0,
|
||||
currentCol: 0,
|
||||
direction: 'right',
|
||||
isAvailable: isTestItemAvailable,
|
||||
});
|
||||
expect(result).toEqual({ id: 3, available: true });
|
||||
});
|
||||
|
||||
it('should scan right multiple times if needed', () => {
|
||||
// From (2,1) {id:7, true} -> right. Next is (2,2) {id:8, false}. Scan right to (2,3) {id:9, true}.
|
||||
const result = getNextMatrixItem({
|
||||
matrix: matrixWithAvailability,
|
||||
currentRow: 2,
|
||||
currentCol: 1,
|
||||
direction: 'right',
|
||||
isAvailable: isTestItemAvailable,
|
||||
});
|
||||
expect(result).toEqual({ id: 9, available: true });
|
||||
});
|
||||
|
||||
it('should return undefined if scanning right hits end of row and no available item is found', () => {
|
||||
// From (0,1) {id:2, false} -> right. Next is (0,2) {id:3, true}.
|
||||
// If we start from (0,1) and item 3 becomes unavailable, we should get undefined.
|
||||
const modifiedMatrix = JSON.parse(JSON.stringify(matrixWithAvailability)); // Deep copy
|
||||
modifiedMatrix[0][2].available = false; // Make id:3 unavailable
|
||||
const result = getNextMatrixItem({
|
||||
matrix: modifiedMatrix,
|
||||
currentRow: 0,
|
||||
currentCol: 1,
|
||||
direction: 'right',
|
||||
isAvailable: isTestItemAvailable,
|
||||
});
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
// --- Horizontal Scan (left) ---
|
||||
it('should scan left to find the next available item', () => {
|
||||
// From (0,2) {id:3, true} -> left. Next is (0,1) {id:2, false}. Scan left to (0,0) {id:1, true}.
|
||||
const result = getNextMatrixItem({
|
||||
matrix: matrixWithAvailability,
|
||||
currentRow: 0,
|
||||
currentCol: 2,
|
||||
direction: 'left',
|
||||
isAvailable: isTestItemAvailable,
|
||||
});
|
||||
expect(result).toEqual({ id: 1, available: true });
|
||||
});
|
||||
|
||||
it('should scan left multiple times if needed', () => {
|
||||
// From (2,3) {id:9, true} -> left. Next is (2,2) {id:8, false}. Scan left to (2,1) {id:7, true}.
|
||||
const result = getNextMatrixItem({
|
||||
matrix: matrixWithAvailability,
|
||||
currentRow: 2,
|
||||
currentCol: 3,
|
||||
direction: 'left',
|
||||
isAvailable: isTestItemAvailable,
|
||||
});
|
||||
expect(result).toEqual({ id: 7, available: true });
|
||||
});
|
||||
|
||||
it('should return undefined if scanning left hits start of row and no available item is found', () => {
|
||||
// From (0,1) {id:2, false} -> left. Next is (0,0) {id:1, true}.
|
||||
// If we start from (0,1) and item 1 becomes unavailable, we should get undefined.
|
||||
const modifiedMatrix = JSON.parse(JSON.stringify(matrixWithAvailability)); // Deep copy
|
||||
modifiedMatrix[0][0].available = false; // Make id:1 unavailable
|
||||
const result = getNextMatrixItem({
|
||||
matrix: modifiedMatrix,
|
||||
currentRow: 0,
|
||||
currentCol: 1,
|
||||
direction: 'left',
|
||||
isAvailable: isTestItemAvailable,
|
||||
});
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
// --- Vertical Scan (up/down - scans left) ---
|
||||
it('should scan left for available item after snapping down', () => {
|
||||
// From (0,2) {id:3, true} -> down. Target row 1. Snaps to (1,1) {id:5, false}.
|
||||
// Scan left: (1,0) {id:4, true}.
|
||||
const result = getNextMatrixItem({
|
||||
matrix: matrixWithAvailability,
|
||||
currentRow: 0,
|
||||
currentCol: 2,
|
||||
direction: 'down',
|
||||
isAvailable: isTestItemAvailable,
|
||||
});
|
||||
expect(result).toEqual({ id: 4, available: true });
|
||||
});
|
||||
|
||||
it('should scan left for available item after snapping up', () => {
|
||||
// From (2,3) {id:9, true} -> up. Target row 1. Snaps to (1,1) {id:5, false}.
|
||||
// Scan left: (1,0) {id:4, true}.
|
||||
const result = getNextMatrixItem({
|
||||
matrix: matrixWithAvailability,
|
||||
currentRow: 2,
|
||||
currentCol: 3,
|
||||
direction: 'up',
|
||||
isAvailable: isTestItemAvailable,
|
||||
});
|
||||
expect(result).toEqual({ id: 4, available: true });
|
||||
});
|
||||
|
||||
it('should skip rows with all unavailable items when moving vertically (up)', () => {
|
||||
// From (5,0) {id:13, true} -> up. Target row 4 has all unavailable items {id:11, false}, {id:12, false}.
|
||||
// Should skip row 4 and row 3 (also unavailable), then find available item in row 2.
|
||||
const result = getNextMatrixItem({
|
||||
matrix: matrixWithAvailability,
|
||||
currentRow: 5,
|
||||
currentCol: 0,
|
||||
direction: 'up',
|
||||
isAvailable: isTestItemAvailable,
|
||||
});
|
||||
expect(result).toEqual({ id: 6, available: true }); // Found in row 2
|
||||
});
|
||||
|
||||
it('should skip empty rows when moving vertically (down)', () => {
|
||||
// From (3,0) {id:10, false} -> down. Target row 4 is empty [].
|
||||
// Should skip empty row 4 and find available item in row 5.
|
||||
const result = getNextMatrixItem({
|
||||
matrix: matrixWithAvailability,
|
||||
currentRow: 3,
|
||||
currentCol: 0,
|
||||
direction: 'down',
|
||||
isAvailable: isTestItemAvailable,
|
||||
});
|
||||
expect(result).toEqual({ id: 13, available: true }); // Found in row 5
|
||||
});
|
||||
|
||||
it('should skip empty rows and move to the next non-empty row for vertical movement (down)', () => {
|
||||
// From (0,0) which is {id:1, true}
|
||||
// Consider a matrix like:
|
||||
// [ [A] ]
|
||||
// [ [] ] <- row 1 is empty, should be skipped
|
||||
// [ [B] ]
|
||||
// From A down, it should skip the empty row 1 and go to row 2, returning B.
|
||||
const customMatrix = [
|
||||
[{ id: 1, available: true }],
|
||||
[], // Empty row
|
||||
[{ id: 2, available: true }],
|
||||
];
|
||||
// Move from (0,0) down. Should skip empty row 1 and return item from row 2.
|
||||
expect(
|
||||
getNextMatrixItem({
|
||||
matrix: customMatrix,
|
||||
currentRow: 0,
|
||||
currentCol: 0,
|
||||
direction: 'down',
|
||||
isAvailable: isTestItemAvailable,
|
||||
})
|
||||
).toEqual({ id: 2, available: true });
|
||||
});
|
||||
|
||||
it('should skip empty rows and move to the next non-empty row for vertical movement (up)', () => {
|
||||
// From (2,0) which is {id:2, true}
|
||||
// Consider a matrix like:
|
||||
// [ [A] ]
|
||||
// [ [] ] <- row 1 is empty, should be skipped
|
||||
// [ [B] ]
|
||||
// From B up, it should skip the empty row 1 and go to row 0, returning A.
|
||||
const customMatrix = [
|
||||
[{ id: 1, available: true }],
|
||||
[], // Empty row
|
||||
[{ id: 2, available: true }],
|
||||
];
|
||||
// Move from (2,0) up. Should skip empty row 1 and return item from row 0.
|
||||
expect(
|
||||
getNextMatrixItem({
|
||||
matrix: customMatrix,
|
||||
currentRow: 2,
|
||||
currentCol: 0,
|
||||
direction: 'up',
|
||||
isAvailable: isTestItemAvailable,
|
||||
})
|
||||
).toEqual({ id: 1, available: true });
|
||||
});
|
||||
|
||||
it('should skip rows where its all unavailable (down)', () => {
|
||||
// From (0,0) which is {id:1, true}
|
||||
// Consider a matrix like:
|
||||
// [ [A] ]
|
||||
// [ [unavailable] ]
|
||||
// [ [B] ]
|
||||
// From A down, it should skip the unavailable row 1 and go to row 2, returning B.
|
||||
const customMatrix = [
|
||||
[{ id: 1, available: true }],
|
||||
[{ id: 3, available: false }], // Row with unavailable item
|
||||
[{ id: 2, available: true }],
|
||||
];
|
||||
|
||||
// Move from (0,0) down. Should skip unavailable row 1 and return item from row 2.
|
||||
expect(
|
||||
getNextMatrixItem({
|
||||
matrix: customMatrix,
|
||||
currentRow: 0,
|
||||
currentCol: 0,
|
||||
direction: 'down',
|
||||
isAvailable: isTestItemAvailable,
|
||||
})
|
||||
).toEqual({ id: 2, available: true });
|
||||
});
|
||||
|
||||
it('should skip rows where its all unavailable (up)', () => {
|
||||
// From (2,0) which is {id:2, true}
|
||||
// Consider a matrix like:
|
||||
// [ [A] ]
|
||||
// [ [unavailable] ]
|
||||
// [ [B] ]
|
||||
// From B up, it should skip the unavailable row 1 and go to row 0, returning A.
|
||||
const customMatrix = [
|
||||
[{ id: 1, available: true }],
|
||||
[{ id: 3, available: false }], // Row with unavailable item
|
||||
[{ id: 2, available: true }],
|
||||
];
|
||||
|
||||
// Move from (2,0) up. Should skip unavailable row 1 and return item from row 0.
|
||||
expect(
|
||||
getNextMatrixItem({
|
||||
matrix: customMatrix,
|
||||
currentRow: 2,
|
||||
currentCol: 0,
|
||||
direction: 'up',
|
||||
isAvailable: isTestItemAvailable,
|
||||
})
|
||||
).toEqual({ id: 1, available: true });
|
||||
});
|
||||
|
||||
it('should skip multiple consecutive unavailable rows (down)', () => {
|
||||
// Test skipping multiple rows with all unavailable items
|
||||
const customMatrix = [
|
||||
[{ id: 1, available: true }],
|
||||
[{ id: 2, available: false }], // Unavailable row 1
|
||||
[{ id: 3, available: false }], // Unavailable row 2
|
||||
[{ id: 4, available: false }], // Unavailable row 3
|
||||
[{ id: 5, available: true }], // Available row 4
|
||||
];
|
||||
|
||||
// Move from (0,0) down. Should skip rows 1, 2, 3 and return item from row 4.
|
||||
expect(
|
||||
getNextMatrixItem({
|
||||
matrix: customMatrix,
|
||||
currentRow: 0,
|
||||
currentCol: 0,
|
||||
direction: 'down',
|
||||
isAvailable: isTestItemAvailable,
|
||||
})
|
||||
).toEqual({ id: 5, available: true });
|
||||
});
|
||||
|
||||
it('should skip multiple consecutive unavailable rows (up)', () => {
|
||||
// Test skipping multiple rows with all unavailable items
|
||||
const customMatrix = [
|
||||
[{ id: 1, available: true }], // Available row 0
|
||||
[{ id: 2, available: false }], // Unavailable row 1
|
||||
[{ id: 3, available: false }], // Unavailable row 2
|
||||
[{ id: 4, available: false }], // Unavailable row 3
|
||||
[{ id: 5, available: true }], // Available row 4
|
||||
];
|
||||
|
||||
// Move from (4,0) up. Should skip rows 3, 2, 1 and return item from row 0.
|
||||
expect(
|
||||
getNextMatrixItem({
|
||||
matrix: customMatrix,
|
||||
currentRow: 4,
|
||||
currentCol: 0,
|
||||
direction: 'up',
|
||||
isAvailable: isTestItemAvailable,
|
||||
})
|
||||
).toEqual({ id: 1, available: true });
|
||||
});
|
||||
|
||||
it('should skip unavailable items when moving horizontally (right)', () => {
|
||||
// Test skipping unavailable items in horizontal movement
|
||||
const customMatrix = [
|
||||
[
|
||||
{ id: 1, available: true }, // col 0 - current
|
||||
{ id: 2, available: false }, // col 1 - unavailable, should skip
|
||||
{ id: 3, available: false }, // col 2 - unavailable, should skip
|
||||
{ id: 4, available: true }, // col 3 - available, should find
|
||||
],
|
||||
];
|
||||
|
||||
// Move from (0,0) right. Should skip unavailable cols 1,2 and return item from col 3.
|
||||
expect(
|
||||
getNextMatrixItem({
|
||||
matrix: customMatrix,
|
||||
currentRow: 0,
|
||||
currentCol: 0,
|
||||
direction: 'right',
|
||||
isAvailable: isTestItemAvailable,
|
||||
})
|
||||
).toEqual({ id: 4, available: true });
|
||||
});
|
||||
|
||||
it('should skip unavailable items when moving horizontally (left)', () => {
|
||||
// Test skipping unavailable items in horizontal movement
|
||||
const customMatrix = [
|
||||
[
|
||||
{ id: 1, available: true }, // col 0 - available, should find
|
||||
{ id: 2, available: false }, // col 1 - unavailable, should skip
|
||||
{ id: 3, available: false }, // col 2 - unavailable, should skip
|
||||
{ id: 4, available: true }, // col 3 - current
|
||||
],
|
||||
];
|
||||
|
||||
// Move from (0,3) left. Should skip unavailable cols 2,1 and return item from col 0.
|
||||
expect(
|
||||
getNextMatrixItem({
|
||||
matrix: customMatrix,
|
||||
currentRow: 0,
|
||||
currentCol: 3,
|
||||
direction: 'left',
|
||||
isAvailable: isTestItemAvailable,
|
||||
})
|
||||
).toEqual({ id: 1, available: true });
|
||||
});
|
||||
|
||||
it('advanced test case #1', () => {
|
||||
// Test skipping unavailable items and finding available item to the right in target row
|
||||
const customMatrix = [
|
||||
[{ id: 1, available: true }],
|
||||
[{ id: 2, available: false }],
|
||||
[{ id: 3, available: false }],
|
||||
[
|
||||
{ id: 4, available: false },
|
||||
{ id: 5, available: true },
|
||||
],
|
||||
];
|
||||
|
||||
expect(
|
||||
getNextMatrixItem({
|
||||
matrix: customMatrix,
|
||||
currentRow: 0,
|
||||
currentCol: 0,
|
||||
direction: 'down',
|
||||
isAvailable: isTestItemAvailable,
|
||||
})
|
||||
).toEqual({ id: 5, available: true });
|
||||
});
|
||||
|
||||
it('should scan both left and right in target row for vertical movement', () => {
|
||||
// Test scanning both directions in target row
|
||||
const customMatrix = [
|
||||
[{ id: 1, available: true }], // Start at (0,0)
|
||||
[{ id: 2, available: false }],
|
||||
[
|
||||
{ id: 3, available: true }, // col 0 - available (left of start)
|
||||
{ id: 4, available: false }, // col 1 - unavailable (would be start col after clamp)
|
||||
{ id: 5, available: true }, // col 2 - available (right of start)
|
||||
],
|
||||
];
|
||||
|
||||
// Move from (0,0) down to row 2. Should find id: 3 (scan left first)
|
||||
expect(
|
||||
getNextMatrixItem({
|
||||
matrix: customMatrix,
|
||||
currentRow: 0,
|
||||
currentCol: 0,
|
||||
direction: 'down',
|
||||
isAvailable: isTestItemAvailable,
|
||||
})
|
||||
).toEqual({ id: 3, available: true });
|
||||
});
|
||||
|
||||
it('should find item to the right when left scan fails for vertical movement', () => {
|
||||
// Test finding item to the right when left scan fails
|
||||
const customMatrix = [
|
||||
[{ id: 1, available: true }], // Start at (0,0)
|
||||
[{ id: 2, available: false }],
|
||||
[
|
||||
{ id: 3, available: false }, // col 0 - unavailable (start col after clamp)
|
||||
{ id: 4, available: true }, // col 1 - available (right of start)
|
||||
],
|
||||
];
|
||||
|
||||
// Move from (0,0) down to row 2. Should find id: 4 (scan right after left fails)
|
||||
expect(
|
||||
getNextMatrixItem({
|
||||
matrix: customMatrix,
|
||||
currentRow: 0,
|
||||
currentCol: 0,
|
||||
direction: 'down',
|
||||
isAvailable: isTestItemAvailable,
|
||||
})
|
||||
).toEqual({ id: 4, available: true });
|
||||
});
|
||||
|
||||
it('should return undefined when no available items found horizontally', () => {
|
||||
// Test when all items in horizontal direction are unavailable
|
||||
const customMatrix = [
|
||||
[
|
||||
{ id: 1, available: true }, // col 0 - current
|
||||
{ id: 2, available: false }, // col 1 - unavailable
|
||||
{ id: 3, available: false }, // col 2 - unavailable
|
||||
],
|
||||
];
|
||||
|
||||
// Move from (0,0) right. Should skip all unavailable items and return undefined.
|
||||
expect(
|
||||
getNextMatrixItem({
|
||||
matrix: customMatrix,
|
||||
currentRow: 0,
|
||||
currentCol: 0,
|
||||
direction: 'right',
|
||||
isAvailable: isTestItemAvailable,
|
||||
})
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// --- Combined Scenarios ---
|
||||
|
||||
describe('Combined Scenarios', () => {
|
||||
it('should handle snapping and then scanning for availability correctly (down)', () => {
|
||||
// Matrix:
|
||||
// [ T, F, F ]
|
||||
// [ F, F ] <- all unavailable, should be skipped
|
||||
// [ T, F, T, T ]
|
||||
// From (0,0) (T) down. Row 1 has all unavailable items, so skip to row 2.
|
||||
// Should find available item at (2,0).
|
||||
const matrix = [
|
||||
[
|
||||
{ id: 1, available: true },
|
||||
{ id: 2, available: false },
|
||||
{ id: 3, available: false },
|
||||
],
|
||||
[
|
||||
{ id: 4, available: false },
|
||||
{ id: 5, available: false },
|
||||
],
|
||||
[
|
||||
{ id: 6, available: true },
|
||||
{ id: 7, available: false },
|
||||
{ id: 8, available: true },
|
||||
{ id: 9, available: true },
|
||||
],
|
||||
];
|
||||
const result = getNextMatrixItem({
|
||||
matrix,
|
||||
currentRow: 0,
|
||||
currentCol: 0,
|
||||
direction: 'down',
|
||||
isAvailable: isTestItemAvailable,
|
||||
});
|
||||
expect(result).toEqual({ id: 6, available: true }); // Found in row 2
|
||||
});
|
||||
|
||||
it('should handle snapping and then scanning for availability correctly (up)', () => {
|
||||
// Matrix:
|
||||
// [ F, F ]
|
||||
// [ T, F, F ]
|
||||
// From (1,0) (T) up. Target row 0. Initial nextCol is 0. Item (0,0) is F.
|
||||
// Snaps to (0,0) (F). Scan left. No more left. Should return undefined.
|
||||
const matrix = [
|
||||
[
|
||||
{ id: 1, available: false },
|
||||
{ id: 2, available: false },
|
||||
],
|
||||
[
|
||||
{ id: 3, available: true },
|
||||
{ id: 4, available: false },
|
||||
{ id: 5, available: false },
|
||||
],
|
||||
];
|
||||
const result = getNextMatrixItem({
|
||||
matrix,
|
||||
currentRow: 1,
|
||||
currentCol: 0,
|
||||
direction: 'up',
|
||||
isAvailable: isTestItemAvailable,
|
||||
});
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -126,3 +126,189 @@ export function* iterate<T>(array: T[]): Generator<IterateReturn<T>> {
|
|||
export function last<T>(arr: T[]): T | undefined {
|
||||
return arr[arr.length - 1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines the possible directions for movement within the matrix.
|
||||
*/
|
||||
export type Direction = 'up' | 'down' | 'left' | 'right';
|
||||
|
||||
/**
|
||||
* Options for the getNextMatrixItem function.
|
||||
* @template T The type of items stored in the matrix.
|
||||
*/
|
||||
export interface GetNextMatrixItemOptions<T> {
|
||||
/** The matrix (an array of arrays) where rows can have varying lengths. */
|
||||
matrix: T[][];
|
||||
/** The 0-based index of the current row. */
|
||||
currentRow: number;
|
||||
/** The 0-based index of the current column. */
|
||||
currentCol: number;
|
||||
/** The direction to move ('up', 'down', 'left', 'right'). */
|
||||
direction: Direction;
|
||||
/**
|
||||
* An optional predicate function that determines if an item is "available".
|
||||
* If an item is not available, the function will attempt to find the next available candidate.
|
||||
* Defaults to always returning true (all items are available).
|
||||
* @param item The item to check.
|
||||
* @returns True if the item is available, false otherwise.
|
||||
*/
|
||||
isAvailable?: (item: T) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the next item in a matrix based on a current position and a direction.
|
||||
* This function is designed to work with "jagged" arrays (rows can have different lengths).
|
||||
*
|
||||
* Special behavior for 'up'/'down': If the target row is shorter than the current column,
|
||||
* the column index will snap to the rightmost valid column of the target row.
|
||||
*
|
||||
* If an `isAvailable` function is provided and the initially calculated next item is
|
||||
* not available, the function will attempt to find the next available candidate
|
||||
* within the target row:
|
||||
* - For 'up'/'down': It will scan left from the snapped column.
|
||||
* - For 'left': It will continue scanning left.
|
||||
* - For 'right': It will continue scanning right.
|
||||
*
|
||||
* @template T The type of items stored in the matrix.
|
||||
* @param options The options object containing matrix, current position, direction, and optional isAvailable predicate.
|
||||
* @returns The item at the next valid and available position, or `undefined` if no such item is found.
|
||||
*/
|
||||
export function getNextMatrixItem<T>(options: GetNextMatrixItemOptions<T>): T | undefined {
|
||||
const { matrix, currentRow, currentCol, direction, isAvailable = (_i) => true } = options;
|
||||
|
||||
// --- 1. Input Validation: Matrix and Current Position ---
|
||||
|
||||
if (!matrix || !Array.isArray(matrix) || matrix.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (currentRow < 0 || currentRow >= matrix.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const currentRowArray = matrix[currentRow];
|
||||
if (
|
||||
!currentRowArray ||
|
||||
!Array.isArray(currentRowArray) ||
|
||||
currentCol < 0 ||
|
||||
currentCol >= currentRowArray.length
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// --- 2. Calculate Tentative Next Coordinates ---
|
||||
|
||||
let nextRow = currentRow;
|
||||
let nextCol = currentCol;
|
||||
|
||||
switch (direction) {
|
||||
case 'up':
|
||||
nextRow--;
|
||||
break;
|
||||
case 'down':
|
||||
nextRow++;
|
||||
break;
|
||||
case 'left':
|
||||
nextCol--;
|
||||
break;
|
||||
case 'right':
|
||||
nextCol++;
|
||||
break;
|
||||
}
|
||||
|
||||
// --- 3. Validate and Adjust Next Coordinates Against Matrix Bounds ---
|
||||
|
||||
// For vertical movements, skip empty rows and rows where all items are unavailable
|
||||
if (direction === 'up' || direction === 'down') {
|
||||
while (nextRow >= 0 && nextRow < matrix.length) {
|
||||
const nextRowArray = matrix[nextRow];
|
||||
if (nextRowArray && Array.isArray(nextRowArray) && nextRowArray.length > 0) {
|
||||
// Check if there's at least one available item in this row
|
||||
const hasAvailableItem = nextRowArray.some(item =>
|
||||
item !== undefined && item !== null && isAvailable(item)
|
||||
);
|
||||
if (hasAvailableItem) {
|
||||
break; // Found a row with at least one available item
|
||||
}
|
||||
}
|
||||
// Skip empty row or row with all unavailable items
|
||||
nextRow += direction === 'down' ? 1 : -1;
|
||||
}
|
||||
|
||||
if (nextRow < 0 || nextRow >= matrix.length) {
|
||||
return undefined; // Out of vertical bounds after skipping empty/unavailable rows
|
||||
}
|
||||
}
|
||||
|
||||
// For horizontal movements, validate row bounds and get the row array
|
||||
if (direction === 'left' || direction === 'right') {
|
||||
if (nextRow < 0 || nextRow >= matrix.length) {
|
||||
return undefined; // Out of vertical bounds
|
||||
}
|
||||
}
|
||||
|
||||
const nextRowArray = matrix[nextRow];
|
||||
if (!nextRowArray || !Array.isArray(nextRowArray)) {
|
||||
return undefined; // The row itself is malformed or non-existent
|
||||
}
|
||||
|
||||
// For horizontal movements, check if the row is empty
|
||||
if ((direction === 'left' || direction === 'right') && nextRowArray.length === 0) {
|
||||
return undefined; // Can't move horizontally in an empty row
|
||||
}
|
||||
|
||||
// --- NEW LOGIC: Adjust nextCol for vertical movements if it's out of bounds ---
|
||||
if (direction === 'up' || direction === 'down') {
|
||||
// Clamp nextCol to the last valid index of the target row if it's too far right
|
||||
nextCol = Math.min(nextCol, nextRowArray.length - 1);
|
||||
}
|
||||
|
||||
// --- 4. Find the Next Available Item ---
|
||||
|
||||
// For horizontal movements, skip empty columns by finding next valid position
|
||||
if (direction === 'left' || direction === 'right') {
|
||||
let candidateCol = nextCol;
|
||||
const increment = direction === 'right' ? 1 : -1;
|
||||
|
||||
while (candidateCol >= 0 && candidateCol < nextRowArray.length) {
|
||||
const candidateItem = nextRowArray[candidateCol];
|
||||
if (candidateItem !== undefined && candidateItem !== null && isAvailable(candidateItem)) {
|
||||
return candidateItem;
|
||||
}
|
||||
candidateCol += increment;
|
||||
}
|
||||
return undefined; // No available item found in the horizontal direction
|
||||
}
|
||||
|
||||
// Initial check for bounds after clamping/calculation
|
||||
if (nextCol < 0 || nextCol >= nextRowArray.length) {
|
||||
return undefined; // No valid column to start searching from in the target row
|
||||
}
|
||||
|
||||
// Loop to find the next available item for vertical movements
|
||||
let candidateCol = nextCol;
|
||||
|
||||
if (direction === 'up' || direction === 'down') {
|
||||
// For vertical moves, try the calculated/clamped 'candidateCol', then scan left, then scan right
|
||||
while (candidateCol >= 0) {
|
||||
const candidateItem = nextRowArray[candidateCol];
|
||||
if (candidateItem !== undefined && candidateItem !== null && isAvailable(candidateItem)) {
|
||||
return candidateItem;
|
||||
}
|
||||
candidateCol--; // Move left to find an available item
|
||||
}
|
||||
|
||||
// If not found scanning left, try scanning right from the original position
|
||||
candidateCol = nextCol + 1;
|
||||
while (candidateCol < nextRowArray.length) {
|
||||
const candidateItem = nextRowArray[candidateCol];
|
||||
if (candidateItem !== undefined && candidateItem !== null && isAvailable(candidateItem)) {
|
||||
return candidateItem;
|
||||
}
|
||||
candidateCol++; // Move right to find an available item
|
||||
}
|
||||
}
|
||||
|
||||
// If no available item was found in the search path
|
||||
return undefined;
|
||||
}
|
||||
|
|
|
|||
50
src/lib/utils/attribute.ts
Normal file
50
src/lib/utils/attribute.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import type { ObjToString } from './types';
|
||||
|
||||
type DataReturn<T> = T extends true ? '' : T extends false ? undefined : T;
|
||||
|
||||
export function dataAttr<T>(value: T): DataReturn<T> {
|
||||
return (value === true ? '' : value === false ? undefined : value) as DataReturn<T>;
|
||||
}
|
||||
|
||||
type DisabledReturn<T> = T extends true ? true : undefined;
|
||||
export function disabledAttr<V extends boolean>(value?: V): DisabledReturn<V> {
|
||||
return (value === true ? true : undefined) as DisabledReturn<V>;
|
||||
}
|
||||
|
||||
export function styleAttr<T extends Record<string, string>>(value: T): ObjToString<T> {
|
||||
return Object.entries(value)
|
||||
.map(([key, value]) => `${key}: ${value};`)
|
||||
.join(' ') as ObjToString<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique, safe ID attribute from a string using hash-based approach
|
||||
*/
|
||||
export function idAttr(value: string): string {
|
||||
if (!value || typeof value !== 'string') {
|
||||
return 'id-empty';
|
||||
}
|
||||
|
||||
// Simple but effective hash function (djb2 algorithm)
|
||||
let hash = 5381;
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
hash = (hash << 5) + hash + value.charCodeAt(i);
|
||||
hash = hash & hash; // Convert to 32-bit integer
|
||||
}
|
||||
|
||||
// Convert to positive number and then to base36 for compactness
|
||||
const hashStr = Math.abs(hash).toString(36);
|
||||
|
||||
// Create readable prefix from original string
|
||||
let prefix = value
|
||||
.replace(/[^a-zA-Z0-9]/g, '')
|
||||
.toLowerCase()
|
||||
.slice(0, 8);
|
||||
|
||||
// Ensure prefix starts with a letter
|
||||
if (!prefix || /^[0-9]/.test(prefix)) {
|
||||
prefix = 'id' + prefix;
|
||||
}
|
||||
|
||||
return `${prefix}-${hashStr}`;
|
||||
}
|
||||
47
src/lib/utils/types.ts
Normal file
47
src/lib/utils/types.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
export type ValueOf<T> = T[keyof T];
|
||||
|
||||
/* God forgive me */
|
||||
export type Expand<T> = T extends infer O ? O : never;
|
||||
|
||||
export type ExpandDeep<T> = T extends object
|
||||
? T extends infer O
|
||||
? { [K in keyof O]: ExpandDeep<O[K]> }
|
||||
: never
|
||||
: T;
|
||||
|
||||
type What<
|
||||
T extends Record<string, string | number | boolean>,
|
||||
Sep extends string = '; ',
|
||||
K extends string = '',
|
||||
> = ExpandDeep<{
|
||||
[key in keyof T]: key extends string
|
||||
? // eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
{} extends Omit<T, key>
|
||||
? `${K}${key}: ${T[key]}`
|
||||
: What<Omit<T, key>, Sep, `${K}${key}: ${T[key]}${Sep}`>
|
||||
: never;
|
||||
}>;
|
||||
|
||||
type UnionToParm<U> = U extends unknown ? (k: U) => void : never;
|
||||
type UnionToSect<U> = UnionToParm<U> extends (k: infer I) => void ? I : never;
|
||||
type ExtractParm<F> = F extends { (a: infer A): void } ? A : never;
|
||||
|
||||
type SpliceOne<Union> = Exclude<Union, ExtractOne<Union>>;
|
||||
type ExtractOne<Union> = ExtractParm<UnionToSect<UnionToParm<Union>>>;
|
||||
|
||||
type ToTuple<Union> = ToTupleRec<Union, []>;
|
||||
type ToTupleRec<Union, Rslt extends unknown[]> =
|
||||
SpliceOne<Union> extends never
|
||||
? [ExtractOne<Union>, ...Rslt]
|
||||
: ToTupleRec<SpliceOne<Union>, [ExtractOne<Union>, ...Rslt]>;
|
||||
|
||||
// @ts-expect-error - this is hacky, but it works
|
||||
type FindWhat<T> = ToTuple<T extends string ? T : FindWhat<T[keyof T]>>[0];
|
||||
|
||||
export type ObjToString<T extends Record<string, string | number | boolean>> = FindWhat<What<T>>;
|
||||
|
||||
export type Expect<T extends true> = T;
|
||||
export type Equal<X, Y> =
|
||||
(<T>() => T extends X ? 1 : 2) extends <T>() => T extends Y ? 1 : 2 ? true : false;
|
||||
|
||||
export type FalseIfUndefined<T extends boolean | undefined> = T extends undefined ? false : T;
|
||||
|
|
@ -19,7 +19,7 @@ html.dark .shiki span {
|
|||
}
|
||||
|
||||
.prose {
|
||||
@apply text-foreground max-w-[65ch];
|
||||
@apply text-foreground;
|
||||
|
||||
& h1:not(.not-prose h1) {
|
||||
@apply scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl;
|
||||
|
|
|
|||
|
|
@ -237,7 +237,7 @@
|
|||
const index = message.current.lastIndexOf('@', cursor);
|
||||
if (index === -1) return;
|
||||
|
||||
const ruleFromCursor = message.slice(index + 1, cursor);
|
||||
const ruleFromCursor = message.current.slice(index + 1, cursor);
|
||||
|
||||
const suggestions: Doc<'user_rules'>[] = [];
|
||||
|
||||
|
|
@ -560,7 +560,7 @@
|
|||
</Tooltip>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 pr-2 sm:flex-row sm:items-center">
|
||||
<ModelPicker />
|
||||
<ModelPicker onlyImageModels={selectedImages.length > 0} />
|
||||
<button
|
||||
type="button"
|
||||
class={cn(
|
||||
|
|
|
|||
|
|
@ -40,7 +40,12 @@
|
|||
</script>
|
||||
|
||||
{#if message.role !== 'system' && !(message.role === 'assistant' && message.content.length === 0)}
|
||||
<div class={cn('group flex max-w-[80%] flex-col gap-1', { 'self-end': message.role === 'user' })}>
|
||||
<div
|
||||
class={cn('group flex flex-col gap-1', {
|
||||
'self-end': message.role === 'user',
|
||||
'max-w-[80%]': message.role === 'user',
|
||||
})}
|
||||
>
|
||||
{#if message.images && message.images.length > 0}
|
||||
<div class="mb-2 flex flex-wrap gap-2">
|
||||
{#each message.images as image (image.storage_id)}
|
||||
|
|
|
|||
|
|
@ -1,46 +1,59 @@
|
|||
<script lang="ts">
|
||||
import { api } from '$lib/backend/convex/_generated/api';
|
||||
import { GridCommand } from '$lib/builders/grid-command.svelte';
|
||||
import { useCachedQuery } from '$lib/cache/cached-query.svelte';
|
||||
import Cohere from '$lib/components/icons/cohere.svelte';
|
||||
import Deepseek from '$lib/components/icons/deepseek.svelte';
|
||||
import Tooltip from '$lib/components/ui/tooltip.svelte';
|
||||
import { IsMobile } from '$lib/hooks/is-mobile.svelte';
|
||||
import { models as modelsState } from '$lib/state/models.svelte';
|
||||
import { session } from '$lib/state/session.svelte';
|
||||
import { settings } from '$lib/state/settings.svelte';
|
||||
import { Provider } from '$lib/types';
|
||||
import fuzzysearch from '$lib/utils/fuzzy-search';
|
||||
import { supportsImages } from '$lib/utils/model-capabilities';
|
||||
import { capitalize } from '$lib/utils/strings';
|
||||
import { cn } from '$lib/utils/utils';
|
||||
import { Command } from 'bits-ui';
|
||||
import { mergeAttrs } from 'melt';
|
||||
import { Popover } from 'melt/builders';
|
||||
import { tick, type Component } from 'svelte';
|
||||
import { type HTMLAttributes } from 'svelte/elements';
|
||||
import LogosClaudeIcon from '~icons/logos/claude-icon';
|
||||
import LogosMistralAiIcon from '~icons/logos/mistral-ai-icon';
|
||||
import BrainIcon from '~icons/lucide/brain';
|
||||
import ChevronDownIcon from '~icons/lucide/chevron-down';
|
||||
import SearchIcon from '~icons/lucide/search';
|
||||
import CpuIcon from '~icons/lucide/cpu';
|
||||
import EyeIcon from '~icons/lucide/eye';
|
||||
import SearchIcon from '~icons/lucide/search';
|
||||
import ZapIcon from '~icons/lucide/zap';
|
||||
import MaterialIconThemeGeminiAi from '~icons/material-icon-theme/gemini-ai';
|
||||
import GoogleIcon from '~icons/simple-icons/google';
|
||||
import MetaIcon from '~icons/simple-icons/meta';
|
||||
import MicrosoftIcon from '~icons/simple-icons/microsoft';
|
||||
import OpenaiIcon from '~icons/simple-icons/openai';
|
||||
import XIcon from '~icons/simple-icons/x';
|
||||
import BrainIcon from '~icons/lucide/brain';
|
||||
import CpuIcon from '~icons/lucide/cpu';
|
||||
import ZapIcon from '~icons/lucide/zap';
|
||||
import Cohere from '$lib/components/icons/cohere.svelte';
|
||||
import Deepseek from '$lib/components/icons/deepseek.svelte';
|
||||
import { Popover } from 'melt/builders';
|
||||
import type { Component } from 'svelte';
|
||||
import LogosClaudeIcon from '~icons/logos/claude-icon';
|
||||
import LogosMistralAiIcon from '~icons/logos/mistral-ai-icon';
|
||||
import MaterialIconThemeGeminiAi from '~icons/material-icon-theme/gemini-ai';
|
||||
import { capitalize } from '$lib/utils/strings';
|
||||
import { supportsImages } from '$lib/utils/model-capabilities';
|
||||
import { models as modelsState } from '$lib/state/models.svelte';
|
||||
import { Provider } from '$lib/types';
|
||||
import Tooltip from '$lib/components/ui/tooltip.svelte';
|
||||
import fuzzysearch from '$lib/utils/fuzzy-search';
|
||||
import { IsMobile } from '$lib/hooks/is-mobile.svelte';
|
||||
|
||||
type Props = {
|
||||
class?: string;
|
||||
/* When images are attached, we should not select models that don't support images */
|
||||
onlyImageModels?: boolean;
|
||||
};
|
||||
|
||||
let { class: className }: Props = $props();
|
||||
let { class: className, onlyImageModels }: Props = $props();
|
||||
|
||||
const enabledModelsQuery = useCachedQuery(api.user_enabled_models.get_enabled, {
|
||||
session_token: session.current?.session.token ?? '',
|
||||
});
|
||||
|
||||
const gridCommand = new GridCommand({
|
||||
columns: () => (isMobile.current ? 1 : 4),
|
||||
onSelect: (value) => {
|
||||
settings.modelId = value;
|
||||
popover.open = false;
|
||||
gridCommand.inputValue = '';
|
||||
},
|
||||
});
|
||||
|
||||
const enabledArr = $derived(Object.values(enabledModelsQuery.data ?? {}));
|
||||
|
||||
modelsState.init();
|
||||
|
|
@ -111,12 +124,10 @@
|
|||
return 'other';
|
||||
}
|
||||
|
||||
let search = $state('');
|
||||
|
||||
const filteredModels = $derived(
|
||||
fuzzysearch({
|
||||
haystack: enabledArr,
|
||||
needle: search,
|
||||
needle: gridCommand.inputValue,
|
||||
property: 'model_id',
|
||||
})
|
||||
);
|
||||
|
|
@ -151,18 +162,17 @@
|
|||
}
|
||||
});
|
||||
|
||||
function selectModel(modelId: string) {
|
||||
settings.modelId = modelId;
|
||||
popover.open = false;
|
||||
}
|
||||
|
||||
let open = $state(false);
|
||||
const popover = new Popover({
|
||||
open: () => open,
|
||||
onOpenChange: (v) => {
|
||||
if (v === open) return;
|
||||
open = v;
|
||||
if (v) return;
|
||||
if (v) {
|
||||
tick().then(() => {
|
||||
gridCommand.scrollToHighlighted();
|
||||
});
|
||||
}
|
||||
document.getElementById(popover.trigger.id)?.focus();
|
||||
},
|
||||
floatingConfig: {
|
||||
|
|
@ -230,19 +240,14 @@
|
|||
{...popover.content}
|
||||
class="border-border bg-popover mt-1 max-h-200 min-w-80 flex-col overflow-hidden rounded-xl border p-0 backdrop-blur-sm data-[open]:flex"
|
||||
>
|
||||
<Command.Root
|
||||
shouldFilter={false}
|
||||
class="flex h-full flex-col overflow-hidden md:w-[572px]"
|
||||
columns={isMobile.current ? undefined : 4}
|
||||
>
|
||||
<div class="flex h-full flex-col overflow-hidden md:w-[572px]" {...gridCommand.root}>
|
||||
<label
|
||||
class="group/label border-border relative flex items-center gap-2 border-b px-4 py-3 text-sm"
|
||||
>
|
||||
<SearchIcon class="text-muted-foreground" />
|
||||
<Command.Input
|
||||
<input
|
||||
class="w-full outline-none"
|
||||
placeholder="Search models..."
|
||||
bind:value={search}
|
||||
{@attach (node) => {
|
||||
if (popover.open) {
|
||||
node.focus();
|
||||
|
|
@ -251,109 +256,95 @@
|
|||
node.value = '';
|
||||
};
|
||||
}}
|
||||
{...mergeAttrs(gridCommand.input as unknown as HTMLAttributes<HTMLElement>, {
|
||||
onkeydown: (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
popover.open = false;
|
||||
gridCommand.inputValue = '';
|
||||
}
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</label>
|
||||
<Command.List class="h-[300px] overflow-y-auto md:h-[430px]">
|
||||
<Command.Viewport>
|
||||
<Command.Empty
|
||||
class="text-muted-foreground flex items-center justify-center p-4 text-sm md:h-[120px]"
|
||||
>
|
||||
No models available. Enable some models in the account settings.
|
||||
</Command.Empty>
|
||||
{#each groupedModels as [company, models] (company)}
|
||||
<Command.Group class="space-y-2">
|
||||
<Command.GroupHeading
|
||||
class="text-heading/75 flex items-center gap-2 px-3 pt-3 pb-1 text-xs font-semibold tracking-wide capitalize md:scroll-m-[180px]"
|
||||
>
|
||||
{company}
|
||||
</Command.GroupHeading>
|
||||
<Command.GroupItems
|
||||
class="flex flex-col gap-2 px-3 pb-3 md:grid md:grid-cols-4 md:gap-3"
|
||||
>
|
||||
{#each models as model (model._id)}
|
||||
{@const isSelected = settings.modelId === model.model_id}
|
||||
{@const formatted = formatModelName(model.model_id)}
|
||||
{#if isMobile.current}
|
||||
<Command.Item
|
||||
value={model.model_id}
|
||||
onSelect={() => selectModel(model.model_id)}
|
||||
class={cn(
|
||||
'border-border flex h-10 items-center justify-between rounded-lg border p-2',
|
||||
'relative scroll-m-2 select-none',
|
||||
'data-selected:bg-accent/50 data-selected:text-accent-foreground',
|
||||
isSelected && 'border-reflect border-none'
|
||||
)}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if getModelIcon(model.model_id)}
|
||||
{@const ModelIcon = getModelIcon(model.model_id)}
|
||||
<ModelIcon class="size-6 shrink-0" />
|
||||
{/if}
|
||||
<p class="font-fake-proxima text-center leading-tight font-bold">
|
||||
{formatted.full}
|
||||
</p>
|
||||
</div>
|
||||
<div class="h-[300px] overflow-y-auto md:h-[430px]">
|
||||
{#each groupedModels as [company, models] (company)}
|
||||
<div {...gridCommand.group} class="space-y-2">
|
||||
<p
|
||||
class="text-heading/75 flex scroll-m-2 items-center gap-2 px-3 pt-3 pb-1 text-xs font-semibold tracking-wide capitalize"
|
||||
{...gridCommand.groupHeading}
|
||||
>
|
||||
{company}
|
||||
</p>
|
||||
<div class="flex flex-col gap-2 px-3 pb-3 md:grid md:grid-cols-4 md:gap-3">
|
||||
{#each models as model (model._id)}
|
||||
{@const isSelected = settings.modelId === model.model_id}
|
||||
{@const formatted = formatModelName(model.model_id)}
|
||||
{@const openRouterModel = modelsState
|
||||
.from(Provider.OpenRouter)
|
||||
.find((m) => m.id === model.model_id)}
|
||||
{@const disabled =
|
||||
onlyImageModels && openRouterModel && !supportsImages(openRouterModel)}
|
||||
|
||||
{@const openRouterModel = modelsState
|
||||
.from(Provider.OpenRouter)
|
||||
.find((m) => m.id === model.model_id)}
|
||||
{#if openRouterModel && supportsImages(openRouterModel)}
|
||||
<Tooltip>
|
||||
{#snippet trigger(tooltip)}
|
||||
<div class="" {...tooltip.trigger}>
|
||||
<EyeIcon class="size-3" />
|
||||
</div>
|
||||
{/snippet}
|
||||
Supports image anaylsis
|
||||
</Tooltip>
|
||||
{/if}
|
||||
</Command.Item>
|
||||
{:else}
|
||||
<Command.Item
|
||||
value={model.model_id}
|
||||
onSelect={() => selectModel(model.model_id)}
|
||||
<div
|
||||
{...gridCommand.getItem(model.model_id, {
|
||||
disabled,
|
||||
})}
|
||||
class={cn(
|
||||
'border-border flex rounded-lg border p-2',
|
||||
'relative scroll-m-2 select-none',
|
||||
'data-highlighted:bg-accent/50 data-highlighted:text-accent-foreground',
|
||||
isSelected && 'border-reflect border-none',
|
||||
isMobile.current
|
||||
? 'h-10 items-center justify-between'
|
||||
: 'h-40 w-32 flex-col items-center justify-center',
|
||||
disabled && 'opacity-50'
|
||||
)}
|
||||
>
|
||||
<div class={cn('flex items-center', isMobile.current ? 'gap-2' : 'flex-col')}>
|
||||
{#if getModelIcon(model.model_id)}
|
||||
{@const ModelIcon = getModelIcon(model.model_id)}
|
||||
<ModelIcon class="size-6 shrink-0" />
|
||||
{/if}
|
||||
|
||||
<p
|
||||
class={cn(
|
||||
'border-border flex h-40 w-32 flex-col items-center justify-center rounded-lg border p-2',
|
||||
'relative select-none',
|
||||
'data-selected:bg-accent/50 data-selected:text-accent-foreground',
|
||||
isSelected && 'border-reflect border-none'
|
||||
'font-fake-proxima text-center leading-tight font-bold',
|
||||
!isMobile.current && 'mt-2'
|
||||
)}
|
||||
>
|
||||
{#if getModelIcon(model.model_id)}
|
||||
{@const ModelIcon = getModelIcon(model.model_id)}
|
||||
<ModelIcon class="size-6 shrink-0" />
|
||||
{/if}
|
||||
<p class="font-fake-proxima mt-2 text-center leading-tight font-bold">
|
||||
{formatted.primary}
|
||||
</p>
|
||||
{isMobile.current ? formatted.full : formatted.primary}
|
||||
</p>
|
||||
|
||||
{#if !isMobile.current}
|
||||
<p class="mt-0 text-center text-xs leading-tight font-medium">
|
||||
{formatted.secondary}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{@const openRouterModel = modelsState
|
||||
.from(Provider.OpenRouter)
|
||||
.find((m) => m.id === model.model_id)}
|
||||
{#if openRouterModel && supportsImages(openRouterModel)}
|
||||
<Tooltip>
|
||||
{#snippet trigger(tooltip)}
|
||||
<div
|
||||
class="abs-x-center text-muted-foreground absolute bottom-3 flex items-center gap-1 text-xs"
|
||||
{...tooltip.trigger}
|
||||
>
|
||||
<EyeIcon class="size-3" />
|
||||
</div>
|
||||
{/snippet}
|
||||
Supports image anaylsis
|
||||
</Tooltip>
|
||||
{/if}
|
||||
</Command.Item>
|
||||
{#if openRouterModel && supportsImages(openRouterModel)}
|
||||
<Tooltip>
|
||||
{#snippet trigger(tooltip)}
|
||||
<div
|
||||
class={cn(
|
||||
isMobile.current
|
||||
? ''
|
||||
: 'abs-x-center text-muted-foreground absolute bottom-3 flex items-center gap-1 text-xs'
|
||||
)}
|
||||
{...tooltip.trigger}
|
||||
>
|
||||
<EyeIcon class="size-3" />
|
||||
</div>
|
||||
{/snippet}
|
||||
Supports image anaylsis
|
||||
</Tooltip>
|
||||
{/if}
|
||||
{/each}
|
||||
</Command.GroupItems>
|
||||
</Command.Group>
|
||||
{/each}
|
||||
</Command.Viewport>
|
||||
</Command.List>
|
||||
</Command.Root>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue