feat(wip): ai command panel

This commit is contained in:
tglide
2023-05-19 15:58:43 +01:00
parent 318d72db14
commit de428016e1
18 changed files with 356 additions and 283 deletions
@@ -0,0 +1,44 @@
<script lang="ts">
import { afterNavigate } from '$app/navigation';
import { Root } from './panels';
import { last } from '$lib/helpers/array';
import { addSubPanel, clearSubPanels, subPanels } from './subPanels';
import { commandCenterKeyDownHandler, disableCommands, registerCommands } from './commands';
$: $registerCommands([
{
callback: () => {
if ($subPanels.length > 0) {
clearSubPanels();
} else {
addSubPanel({
name: 'commandPanel',
component: Root
});
}
},
keys: ['k'],
ctrl: true,
forceEnable: true
}
]);
$: openSubPanel = last($subPanels) ?? null;
$: $disableCommands(!!openSubPanel);
$: if (openSubPanel) {
document.documentElement.classList.add('u-overflow-hidden');
} else {
document.documentElement.classList.remove('u-overflow-hidden');
}
afterNavigate(() => {
clearSubPanels();
});
</script>
<svelte:window on:keydown={$commandCenterKeyDownHandler} />
{#if openSubPanel}
<svelte:component this={openSubPanel.component} />
{/if}
@@ -1,7 +1,8 @@
import { debounce } from '$lib/helpers/debounce';
import { isMac } from '$lib/helpers/platform';
import { derived, writable } from 'svelte/store';
import { debounce } from './debounce';
import { isMac } from './platform';
// Store
export type Command = {
keys: string[];
/* Ctrl on Windows/Linux, Meta on Mac */
@@ -15,73 +16,27 @@ export type Command = {
forceEnable?: boolean;
};
type CommandCenterState = {
commandMap: Map<string, Command[]>;
disabledMap: Map<string, boolean>;
};
export const commandMap = writable<Map<string, Command[]>>(new Map());
export const disabledMap = writable<Map<string, boolean>>(new Map());
export const commandCenter = writable<CommandCenterState>({
commandMap: new Map(),
disabledMap: new Map()
// Derived stores
export const commands = derived(commandMap, ($commandMap) => {
return Array.from($commandMap.values()).flat();
});
export const commands = derived(commandCenter, ($commandCenter) => {
return Array.from($commandCenter.commandMap.values()).flat();
});
const commandsEnabled = derived(commandCenter, ($commandCenter) => {
const commandsEnabled = derived(disabledMap, ($disabledMap) => {
// If there's an item on the disabledMap that's true, then disable the command center
return Array.from($commandCenter.disabledMap.values()).every((disabled) => !disabled);
return Array.from($disabledMap.values()).every((disabled) => !disabled);
});
export const registerCommands = {
subscribe(runner: (cb: (newCommands: Command[]) => void) => void) {
const uuid = crypto.randomUUID();
runner((newCommands: Command[]) => {
commandCenter.update((curr) => {
curr.commandMap.set(uuid, newCommands);
return curr;
});
});
return () => {
commandCenter.update((curr) => {
curr.commandMap.delete(uuid);
return curr;
});
};
}
};
export const disableCommands = {
subscribe(runner: (cb: (disabled: boolean) => void) => void) {
const uuid = crypto.randomUUID();
runner((disabled: boolean) => {
commandCenter.update((curr) => {
curr.disabledMap.set(uuid, disabled);
return curr;
});
});
return () => {
commandCenter.update((curr) => {
curr.disabledMap.delete(uuid);
return curr;
});
};
}
};
function isInputEvent(event: KeyboardEvent) {
return ['INPUT', 'TEXTAREA', 'SELECT'].includes((event.target as HTMLElement).tagName);
}
export const commandCenterKeyDownHandler = derived(
[commandCenter, commandsEnabled],
([{ commandMap }, enabled]) => {
const commandsArr = Array.from(commandMap.values()).flat();
[commandMap, commandsEnabled],
([$commandMap, enabled]) => {
const commandsArr = Array.from($commandMap.values()).flat();
let recentKeyCodes: number[] = [];
return (event: KeyboardEvent) => {
@@ -113,3 +68,44 @@ export const commandCenterKeyDownHandler = derived(
};
}
);
// Methods
export const registerCommands = {
subscribe(runner: (cb: (newCommands: Command[]) => void) => void) {
const uuid = crypto.randomUUID();
runner((newCommands: Command[]) => {
commandMap.update((curr) => {
curr.set(uuid, newCommands);
return curr;
});
});
return () => {
commandMap.update((curr) => {
curr.delete(uuid);
return curr;
});
};
}
};
export const disableCommands = {
subscribe(runner: (cb: (disabled: boolean) => void) => void) {
const uuid = crypto.randomUUID();
runner((disabled: boolean) => {
disabledMap.update((curr) => {
curr.set(uuid, disabled);
return curr;
});
});
return () => {
disabledMap.update((curr) => {
curr.delete(uuid);
return curr;
});
};
}
};
+4
View File
@@ -0,0 +1,4 @@
export * from './commands';
export * from './subPanels';
import CommandCenter from './commandCenter.svelte';
export { CommandCenter };
+5
View File
@@ -0,0 +1,5 @@
<script lang="ts">
import Template from './template.svelte';
</script>
<Template>Ask AI yaaaay</Template>
+4
View File
@@ -0,0 +1,4 @@
import AI from './ai.svelte';
import Root from './root.svelte';
import Template from './template.svelte';
export { AI, Root, Template };
+54
View File
@@ -0,0 +1,54 @@
<!--
This is the root command panel. It precedes all other command panels.
-->
<script lang="ts">
import { commands } from '../commands';
import { isMac } from '$lib/helpers/platform';
import Template from './template.svelte';
let search = '';
$: results = $commands.filter((command) => {
return (
!command.disabled &&
command.label &&
command.label.toLowerCase().includes(search.toLowerCase())
);
});
</script>
<Template options={results}>
<div slot="option" class="u-flex u-main-space-between content" let:option={command}>
<span>
{command.label}
</span>
<div class="u-flex u-gap-4 u-cross-center">
{#if command.ctrl}
<kbd class="kbd"> {isMac() ? '⌘' : 'ctrl'} </kbd>
{/if}
{#if command.shift}
<kbd class="kbd"> {isMac() ? '⇧' : 'shift'} </kbd>
{/if}
{#if command.alt}
<kbd class="kbd"> {isMac() ? '⌥' : 'alt'} </kbd>
{/if}
{#each command.keys as key, i}
{@const hasNext = command.keys.length - 1 !== i}
<kbd class="kbd">
{key.toUpperCase()}
</kbd>
{#if hasNext}
<span class="u-margin-inline-4" style:opacity={0.5}>then</span>
{/if}
{/each}
</div>
</div>
<svelte:fragment slot="no-options">No commands found</svelte:fragment>
</Template>
<style>
.kbd {
padding-inline: 0.25rem;
}
</style>
@@ -0,0 +1,130 @@
<!--
This is the template for all panels used in the command center.
Use this component when you want to create a new panel.
-->
<script lang="ts">
import { popSubPanel, subPanels } from '../subPanels';
import Dialog from '$lib/components/dialog.svelte';
import { quadOut } from 'svelte/easing';
import { crossfade } from 'svelte/transition';
type BaseOption = { callback: () => void };
type Option = $$Generic<BaseOption>;
export let options: Option[] | null = null;
export let search = '';
let selected = 0;
function handleKeyDown(event: KeyboardEvent) {
if (!open) return;
if (event.key === 'ArrowDown') {
event.preventDefault();
if (event.metaKey) {
selected = options.length - 1;
} else {
selected = selected === options.length - 1 ? options.length - 1 : selected + 1;
}
} else if (event.key === 'ArrowUp') {
event.preventDefault();
if (event.metaKey) {
selected = 0;
} else {
selected = selected === 0 ? 0 : selected - 1;
}
} else if (event.key === 'Enter') {
event.preventDefault();
if (options[selected]) {
options[selected].callback();
}
} else if (event.key === 'Home') {
event.preventDefault();
selected = 0;
} else if (event.key === 'End') {
event.preventDefault();
selected = options.length - 1;
}
}
$: {
options;
selected = 0;
}
const [send, receive] = crossfade({
duration: 150,
easing: quadOut
});
</script>
<svelte:window on:keydown={handleKeyDown} />
<Dialog on:close={popSubPanel}>
<div class="u-flex u-flex-vertical u-width-full-line">
{#if $subPanels.length}
aaa
{/if}
<input type="text" placeholder="type here..." autofocus bind:value={search} />
{#if options}
<ul class="u-margin-block-start-16 u-flex u-flex-vertical u-gap-8">
{#each options as option, i}
<li class="result" data-selected={selected === i ? true : undefined}>
{#if selected === i}
<div
class="bg"
in:send|local={{ key: 'bg' }}
out:receive|local={{ key: 'bg' }} />
{/if}
<div class="option">
<slot name="option" {option} />
</div>
</li>
{:else}
<li class="result">
<slot name="no-options">
<span class="text">No options found</span>
</slot>
</li>
{/each}
</ul>
{:else}
<slot />
{/if}
</div>
</Dialog>
<style>
input {
border: none;
background-color: transparent;
}
.result {
padding: 0.5rem 0.75rem;
transition: 150ms;
position: relative;
opacity: 0.65;
transition: 75ms cubic-bezier(0.5, 1, 0.89, 1);
}
.result[data-selected] {
opacity: 1;
transition: 150ms cubic-bezier(0.5, 1, 0.89, 1);
}
.result .bg {
position: absolute;
inset: 0;
background-color: hsl(var(--color-neutral-200));
border-radius: 0.75rem;
translate: 0 -1px;
}
.result .option {
position: relative;
z-index: 10;
}
</style>
+32
View File
@@ -0,0 +1,32 @@
import type { SvelteComponentDev } from 'svelte/internal';
import { writable } from 'svelte/store';
export type SubPanel = {
name: string;
component: typeof SvelteComponentDev;
};
type CastSubPanel = Omit<SubPanel, 'component'> & {
component: unknown;
};
export const subPanels = writable<Array<SubPanel>>([]);
//
export function addSubPanel(subPanel: CastSubPanel) {
subPanels.update((curr) => {
curr.push(subPanel as SubPanel);
return curr;
});
}
export function popSubPanel() {
subPanels.update((curr) => {
curr = curr.slice(0, -1);
return curr;
});
}
export function clearSubPanels() {
subPanels.set([]);
}
+6 -48
View File
@@ -1,28 +1,12 @@
<script lang="ts">
import { trackEvent } from '$lib/actions/analytics';
import { createEventDispatcher, onDestroy, onMount } from 'svelte';
import { createEventDispatcher } from 'svelte';
import { fade, scale } from 'svelte/transition';
export let show = false;
export let size: 'small' | 'big' = null;
export let error: string = null;
export let closable = true;
export let headerDivider = true;
export let animate = true;
let dialog: HTMLDivElement;
let alert: HTMLElement;
const dispatch = createEventDispatcher();
onMount(() => {
if (show) openModal();
});
onDestroy(() => {
if (show) closeModal();
});
function handleBLur(event: MouseEvent) {
if (event.target === dialog) {
trackEvent('click_close_modal', {
@@ -31,16 +15,9 @@
closeModal();
}
}
function openModal() {
document.documentElement.classList.add('u-overflow-hidden');
}
function closeModal() {
if (closable) {
dispatch('close');
show = false;
document.documentElement.classList.remove('u-overflow-hidden');
}
dispatch('close');
}
function handleKeydown(event: KeyboardEvent) {
@@ -52,34 +29,15 @@
closeModal();
}
}
$: if (show) {
openModal();
} else {
closeModal();
}
$: if (error) {
alert?.scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'nearest' });
}
</script>
<svelte:window on:mousedown={handleBLur} on:keydown={handleKeydown} />
{#if show}
<div
class="dialog"
class:is-small={size === 'small'}
class:is-big={size === 'big'}
class:is-separate-header={headerDivider}
on:cancel|preventDefault
bind:this={dialog}
transition:fade={{ duration: animate ? 150 : 0 }}>
<div class="card" transition:scale={{ duration: animate ? 150 : 0, start: 0.9 }}>
<slot />
</div>
<div class="dialog" bind:this={dialog} transition:fade={{ duration: 150 }}>
<div class="card" transition:scale={{ duration: 150, start: 0.9 }}>
<slot />
</div>
{/if}
</div>
<style>
.dialog {
+14 -4
View File
@@ -16,10 +16,22 @@
import Create from './createOrganization.svelte';
import { goto } from '$app/navigation';
import { registerCommands, commandCenterKeyDownHandler } from '$lib/helpers/commandCenter';
import CommandCenter from './commandCenter.svelte';
import { addSubPanel } from '$lib/commandCenter/subPanels';
import { registerCommands, CommandCenter } from '$lib/commandCenter';
import { AI } from '$lib/commandCenter/panels';
$: $registerCommands([
{
label: 'Ask AI',
callback: () => {
addSubPanel({
name: 'Ask AI',
component: AI
});
},
keys: ['a', 'i']
},
{
label: 'Go to Account',
callback: () => {
@@ -69,8 +81,6 @@
}
</script>
<svelte:window on:keydown={$commandCenterKeyDownHandler} />
<CommandCenter />
<Shell
-166
View File
@@ -1,166 +0,0 @@
<script lang="ts">
import { afterNavigate } from '$app/navigation';
import Dialog from '$lib/components/dialog.svelte';
import { commands, disableCommands, registerCommands } from '$lib/helpers/commandCenter';
import { isMac } from '$lib/helpers/platform';
import { quadOut } from 'svelte/easing';
import { crossfade } from 'svelte/transition';
let open = false;
let search = '';
let selected = 0;
$: $registerCommands([
{
callback: () => {
open = !open;
},
keys: ['k'],
ctrl: true,
forceEnable: true
}
]);
$: results = $commands.filter((command) => {
return (
!command.disabled &&
command.label &&
command.label.toLowerCase().includes(search.toLowerCase())
);
});
function handleKeyDown(event: KeyboardEvent) {
if (!open) return;
if (event.key === 'ArrowDown') {
event.preventDefault();
if (event.metaKey) {
selected = results.length - 1;
} else {
selected = selected === results.length - 1 ? results.length - 1 : selected + 1;
}
} else if (event.key === 'ArrowUp') {
event.preventDefault();
if (event.metaKey) {
selected = 0;
} else {
selected = selected === 0 ? 0 : selected - 1;
}
} else if (event.key === 'Enter') {
event.preventDefault();
if (results[selected]) {
results[selected].callback();
open = false;
search = '';
}
} else if (event.key === 'Home') {
event.preventDefault();
selected = 0;
} else if (event.key === 'End') {
event.preventDefault();
selected = results.length - 1;
}
}
$: {
results;
selected = 0;
}
$: $disableCommands(open);
const [send, receive] = crossfade({
duration: 150,
easing: quadOut
});
afterNavigate(() => {
open = false;
search = '';
});
</script>
<svelte:window on:keydown={handleKeyDown} />
<Dialog bind:show={open}>
<div class="u-flex u-flex-vertical u-width-full-line">
<input type="text" placeholder="type here..." autofocus bind:value={search} />
<ul class="u-margin-block-start-16 u-flex u-flex-vertical u-gap-8">
{#each results as command, i}
<li class="result" data-selected={selected === i ? true : undefined}>
{#if selected === i}
<div class="bg" in:send={{ key: 'bg' }} out:receive={{ key: 'bg' }} />
{/if}
<div class="u-flex u-main-space-between content">
<span>
{command.label}
</span>
<div class="u-flex u-gap-4 u-cross-center">
{#if command.ctrl}
<kbd class="kbd"> {isMac() ? '⌘' : 'ctrl'} </kbd>
{/if}
{#if command.shift}
<kbd class="kbd"> {isMac() ? '⇧' : 'shift'} </kbd>
{/if}
{#if command.alt}
<kbd class="kbd"> {isMac() ? '⌥' : 'alt'} </kbd>
{/if}
{#each command.keys as key, i}
{@const hasNext = command.keys.length - 1 !== i}
<kbd class="kbd">
{key.toUpperCase()}
</kbd>
{#if hasNext}
<span class="u-margin-inline-4" style:opacity={0.5}>then</span>
{/if}
{/each}
</div>
</div>
</li>
{:else}
<li class="result">
<span class="text">No commands found</span>
</li>
{/each}
</ul>
</div>
</Dialog>
<style>
input {
border: none;
background-color: transparent;
}
.result {
padding: 0.5rem 0.75rem;
transition: 150ms;
position: relative;
opacity: 0.65;
transition: 75ms cubic-bezier(0.5, 1, 0.89, 1);
}
.result[data-selected] {
opacity: 1;
transition: 150ms cubic-bezier(0.5, 1, 0.89, 1);
}
.result .content {
position: relative;
z-index: 10;
}
.result .bg {
position: absolute;
inset: 0;
background-color: hsl(var(--color-neutral-200));
border-radius: 0.75rem;
translate: 0 -1px;
}
.kbd {
padding-inline: 0.25rem;
}
</style>
@@ -2,10 +2,11 @@
import { goto } from '$app/navigation';
import { base } from '$app/paths';
import { page } from '$app/stores';
import { registerCommands } from '$lib/commandCenter';
import { CardContainer, Empty, GridItem1, Heading, PaginationWithLimit } from '$lib/components';
import { Pill } from '$lib/elements';
import { Button } from '$lib/elements/forms';
import { registerCommands } from '$lib/helpers/commandCenter';
import { Container } from '$lib/layout';
import CreateOrganization from '../createOrganization.svelte';
import type { PageData } from './$types';
@@ -3,8 +3,9 @@
import { sdk } from '$lib/stores/sdk';
import { onMount } from 'svelte';
import { project, stats } from './store';
import { registerCommands } from '$lib/helpers/commandCenter';
import { goto } from '$app/navigation';
import { registerCommands } from '$lib/commandCenter';
onMount(async () => {
return sdk.forConsole.client.subscribe(['project', 'console'], (response) => {
@@ -27,7 +27,7 @@
import { project } from '../store';
import type { PageData } from './$types';
import Create from './createUser.svelte';
import { registerCommands } from '$lib/helpers/commandCenter';
import { registerCommands } from '$lib/commandCenter';
export let data: PageData;
@@ -12,7 +12,7 @@
import Grid from './grid.svelte';
import { columns } from './store';
import Table from './table.svelte';
import { registerCommands } from '$lib/helpers/commandCenter';
import { registerCommands } from '$lib/commandCenter';
export let data: PageData;
@@ -19,7 +19,7 @@
import type { PageData } from './$types';
import Create from './createFunction.svelte';
import { registerCommands } from '$lib/helpers/commandCenter';
import { registerCommands } from '$lib/commandCenter';
export let data: PageData;
@@ -36,7 +36,7 @@
import { toLocaleDateTime } from '$lib/helpers/date';
import { app } from '$lib/stores/app';
import type { PageData } from './$types';
import { registerCommands } from '$lib/helpers/commandCenter';
import { registerCommands } from '$lib/commandCenter';
export let data: PageData;
@@ -15,7 +15,7 @@
import { Button } from '$lib/elements/forms';
import { Container } from '$lib/layout';
import type { Models } from '@appwrite.io/console';
import { registerCommands } from '$lib/helpers/commandCenter';
import { registerCommands } from '$lib/commandCenter';
import type { PageData } from './$types';
import Create from './create.svelte';