Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
"dependencies": {
"@lucide/vue": "^1.41.0",
"@nvisy/console": "*",
"@nvisy/sdk": "^0.48.0",
"@nvisy/sdk": "^0.49.0",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-deep-link": "^2",
"@tauri-apps/plugin-http": "^2.6.0",
Expand Down
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
"dependencies": {
"@nuxtjs/device": "^4.0.0",
"@nvisy/console": "*",
"@nvisy/sdk": "^0.48.0",
"@nvisy/sdk": "^0.49.0",
"jszip": "^3.10.1",
"nuxt": "^4.5.2",
"vue": "^3.5.42",
Expand Down
12 changes: 6 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

61 changes: 61 additions & 0 deletions packages/console/app/components/layout/AppChat.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<script setup lang="ts">
import { Sheet, SheetContent } from "#console/components/ui/sheet";
import SheetHeader from "#console/components/ui/sheet/SheetHeader.vue";
import SheetTitle from "#console/components/ui/sheet/SheetTitle.vue";
import SheetDescription from "#console/components/ui/sheet/SheetDescription.vue";
import { useSidebar } from "#console/components/ui/sidebar";
import AppChatPanel from "#console/components/layout/chat/AppChatPanel.vue";

/**
* The global workspace chat rail. On desktop it fills the shell's `[chat]` grid
* track (see SidebarProvider), which reflows the content card when open; on
* mobile it's an overlay sheet from the right so a narrow viewport isn't
* squeezed. Both host the same `AppChatPanel`. Mounted once in the shell layout.
*/
const { t } = useI18n();
const { isOpen, close } = useChatPanel();
const { isMobile } = useSidebar();
const { loadSessions } = useChat();

// Load the workspace's chat sessions the first time the rail opens, so an unused
// chat never fetches on every page.
watch(
isOpen,
(open) => {
if (open) loadSessions();
},
{ immediate: true },
);
</script>

<template>
<Sheet
v-if="isMobile"
:open="isOpen"
@update:open="(value: boolean) => !value && close()"
>
<SheetContent side="right" class="w-[min(22rem,90vw)] bg-sidebar p-0">
<SheetHeader class="sr-only">
<SheetTitle>{{ t("chat.title") }}</SheetTitle>
<SheetDescription>{{ t("chat.description") }}</SheetDescription>
</SheetHeader>
<AppChatPanel />
</SheetContent>
</Sheet>

<!-- `v-show` (not `v-if`) keeps the streamed conversation mounted across
toggles; the track collapses to zero width when closed (see the grid). -->
<aside
v-else
v-show="isOpen"
class="chat-rail bg-sidebar flex h-full min-h-0 min-w-0 flex-col overflow-hidden"
>
<AppChatPanel />
</aside>
</template>

<style scoped>
.chat-rail {
grid-column: chat / -1;
}
</style>
21 changes: 21 additions & 0 deletions packages/console/app/components/layout/AppHeader.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
<script setup lang="ts">
import { Sparkles } from "@lucide/vue";
import { Separator } from "#console/components/ui/separator";
import { SidebarTrigger } from "#console/components/ui/sidebar";
import { Button } from "#console/components/ui/button";
import {
Breadcrumb,
BreadcrumbItem,
Expand All @@ -9,6 +11,13 @@ import {
} from "#console/components/ui/breadcrumb";
import { NotificationsDropdown } from "#console/components/layout/header";

const { toggle: toggleChat, isOpen: chatOpen } = useChatPanel();
// The chat is workspace-scoped; hide its toggle until a workspace is resolved so
// opening the panel can't fire workspace-required calls (loadSessions) with no
// active workspace.
const { currentWorkspaceSlug } = useWorkspaces();
const hasWorkspace = computed(() => !!currentWorkspaceSlug.value);

const route = useRoute();

// The page category (a `header.category.*` i18n key) is set per page via
Expand Down Expand Up @@ -65,6 +74,18 @@ const pageCategory = computed(() =>

<!-- Right: persistent app chrome. -->
<div class="flex shrink-0 items-center gap-2">
<Button
v-if="hasWorkspace"
variant="ghost"
size="icon-sm"
class="size-8 text-muted-foreground hover:text-foreground"
:class="chatOpen && 'bg-muted text-foreground'"
:aria-label="t('chat.toggle')"
:aria-pressed="chatOpen"
@click="toggleChat"
>
<Sparkles :size="18" />
</Button>
<NotificationsDropdown />
</div>
</header>
Expand Down
210 changes: 210 additions & 0 deletions packages/console/app/components/layout/chat/AppChatPanel.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
<script setup lang="ts">
import { Plus, X, Sparkles, Trash2, MessageSquarePlus } from "@lucide/vue";
import { Button } from "#console/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "#console/components/ui/dropdown-menu";
import ChatMessage from "#console/components/layout/chat/ChatMessage.vue";
import ChatInput from "#console/components/layout/chat/ChatInput.vue";

const { t } = useI18n();
const { wLink } = useWorkspaceLink();
const { close } = useChatPanel();

const {
sessions,
currentSession,
currentSessionId,
messages,
isStreaming,
needsProvider,
error,
selectSession,
newSession,
deleteSession,
sendMessage,
} = useChat();

const draft = ref("");
const listEl = ref<HTMLElement | null>(null);

// A send/stream failure maps to actionable copy; anything else is unexpected.
const errorMessage = computed(() =>
error.value ? t(`chat.error.${error.value}`) : null,
);

// Keep the newest message in view as the conversation grows and as the reply
// streams in — but only when the user is already near the bottom, so scrolling
// up to read earlier text mid-stream isn't yanked back down.
function nearBottom(): boolean {
const el = listEl.value;
if (!el) return true;
return el.scrollHeight - el.scrollTop - el.clientHeight < 80;
}
function scrollToBottom() {
nextTick(() => {
const el = listEl.value;
if (el) el.scrollTop = el.scrollHeight;
});
}
// A new message (the user's own send, or a fresh assistant turn) always scrolls;
// streaming tokens only when the user hasn't scrolled away.
watch(() => messages.value.length, scrollToBottom);
watch(
() => messages.value.at(-1)?.content,
() => {
if (isStreaming.value && nearBottom()) scrollToBottom();
},
);
onMounted(scrollToBottom);

async function handleSend() {
const text = draft.value;
draft.value = "";
await sendMessage(text);
// A failure before the message was accepted (e.g. session create failed)
// leaves the text unsent — restore it so the user doesn't lose what they typed.
if (error.value === "send") draft.value = text;
}

function handleRetry() {
if (isStreaming.value) return;
const lastUser = [...messages.value].reverse().find((m) => m.role === "user");
if (lastUser) sendMessage(lastUser.content);
}
</script>

<template>
<div class="flex h-full min-h-0 flex-col">
<!-- Header: session switcher, new chat, close. -->
<div
class="flex h-11 shrink-0 items-center gap-1.5 border-b border-border/50 px-3"
>
<Sparkles :size="16" class="shrink-0 text-muted-foreground" />
<DropdownMenu>
<DropdownMenuTrigger as-child>
<button
type="button"
class="min-w-0 flex-1 truncate rounded-md px-1.5 py-1 text-left text-sm font-medium outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring/50"
>
{{ currentSession?.title ?? t("chat.newChat") }}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" class="w-64">
<DropdownMenuItem @select="newSession">
<MessageSquarePlus :size="15" class="mr-2" />
{{ t("chat.newChat") }}
</DropdownMenuItem>
<template v-if="sessions.length">
<DropdownMenuSeparator />
<div
v-for="s in sessions"
:key="s.id"
class="group/session flex items-center"
>
<DropdownMenuItem
class="min-w-0 flex-1"
:class="s.id === currentSessionId && 'bg-muted'"
@select="selectSession(s.id)"
>
<span class="truncate">{{ s.title }}</span>
</DropdownMenuItem>
<button
type="button"
class="mr-1 rounded p-1 text-muted-foreground opacity-0 hover:text-destructive group-hover/session:opacity-100"
:aria-label="t('chat.deleteSession')"
@click.stop="deleteSession(s.id)"
>
<Trash2 :size="13" />
</button>
</div>
</template>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="ghost"
size="icon-sm"
class="size-7 shrink-0 text-muted-foreground hover:text-foreground"
:aria-label="t('chat.newChat')"
@click="newSession"
>
<Plus :size="16" />
</Button>
<Button
variant="ghost"
size="icon-sm"
class="size-7 shrink-0 text-muted-foreground hover:text-foreground"
:aria-label="t('chat.close')"
@click="close"
>
<X :size="16" />
</Button>
</div>

<!-- No inference provider: prompt to connect one instead of erroring. -->
<div
v-if="needsProvider"
class="flex flex-1 flex-col items-center justify-center gap-3 p-6 text-center"
>
<div
class="flex size-10 items-center justify-center rounded-lg bg-muted/50"
>
<Sparkles :size="20" class="text-muted-foreground" />
</div>
<p class="text-sm font-medium text-foreground">
{{ t("chat.needsProviderTitle") }}
</p>
<p class="max-w-xs text-xs text-muted-foreground">
{{ t("chat.needsProviderDescription") }}
</p>
<Button as-child size="sm" variant="outline">
<NuxtLink :to="wLink('/integrations/providers')">
{{ t("chat.needsProviderCta") }}
</NuxtLink>
</Button>
</div>

<!-- Empty conversation. -->
<div
v-else-if="messages.length === 0"
class="flex flex-1 flex-col items-center justify-center gap-3 p-6 text-center"
>
<div
class="flex size-10 items-center justify-center rounded-lg bg-muted/50"
>
<Sparkles :size="20" class="text-muted-foreground" />
</div>
<p class="text-sm font-medium text-foreground">
{{ t("chat.emptyTitle") }}
</p>
<p class="max-w-xs text-xs text-muted-foreground">
{{ t("chat.emptyDescription") }}
</p>
</div>

<!-- Conversation. -->
<div v-else ref="listEl" class="flex-1 space-y-4 overflow-y-auto px-3 py-4">
<ChatMessage
v-for="m in messages"
:key="m.id"
:message="m"
@try-again="handleRetry"
/>
</div>

<!-- Input, hidden while the no-provider CTA is showing. -->
<div v-if="!needsProvider" class="shrink-0 border-t border-border/50 p-3">
<p
v-if="errorMessage"
class="mb-2 rounded-md border border-destructive/20 bg-destructive/10 px-2.5 py-1.5 text-xs text-destructive"
>
{{ errorMessage }}
</p>
<ChatInput v-model="draft" :disabled="isStreaming" @send="handleSend" />
</div>
</div>
</template>
Loading
Loading