Compare commits

...

10 Commits

Author SHA1 Message Date
lucaronin
d3741c82f5 test: fix starter-vault shell regression 2026-04-20 11:20:27 +02:00
lucaronin
b90d2f8612 fix: reorder welcome onboarding actions 2026-04-20 11:04:29 +02:00
lucaronin
9536efee0e fix: persist AI panel chats across reopen 2026-04-20 10:28:48 +02:00
lucaronin
c86a6ab9c5 docs: add/update ADR for starter vault local-first remote flow (guard — from commit 374b4f66) 2026-04-20 10:28:00 +02:00
Luca Rossi
1d7c027700 Updated Tolaria Icon 2026-04-20 10:25:46 +02:00
lucaronin
74e50c64bb fix: hide stale getting started vault entries 2026-04-20 01:02:08 +02:00
lucaronin
8b97ef711f feat: refresh welcome screen branding 2026-04-20 00:45:14 +02:00
lucaronin
08855cee92 fix: defer git history during note switching 2026-04-20 00:09:52 +02:00
lucaronin
03042bb49c fix: normalize shipped telemetry secrets 2026-04-19 23:27:57 +02:00
lucaronin
cee4bef179 fix: harden shipped telemetry config 2026-04-19 23:16:34 +02:00
32 changed files with 1133 additions and 146 deletions

View File

@@ -127,6 +127,63 @@ jobs:
security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV"
- name: Validate telemetry env
env:
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
run: |
python3 <<'PY'
import os
import sys
from urllib.parse import urlparse
def normalize(name: str) -> str:
value = os.getenv(name, "").strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
value = value[1:-1].strip()
return value
def normalize_http_like(value: str) -> str:
if "://" in value:
return value
return f"https://{value}"
def is_http_url(value: str) -> bool:
parsed = urlparse(normalize_http_like(value))
return parsed.scheme in {"http", "https"} and bool(parsed.netloc)
values = {
name: normalize(name)
for name in (
"VITE_SENTRY_DSN",
"SENTRY_DSN",
"VITE_POSTHOG_KEY",
"VITE_POSTHOG_HOST",
)
}
errors = []
for name in ("VITE_SENTRY_DSN", "SENTRY_DSN", "VITE_POSTHOG_HOST"):
value = values[name]
if not value:
errors.append(f"{name} must be set for release builds")
elif not is_http_url(value):
errors.append(f"{name} must be a valid http(s) URL")
if not values["VITE_POSTHOG_KEY"]:
errors.append("VITE_POSTHOG_KEY must be set for release builds")
if errors:
print("Telemetry env validation failed:", file=sys.stderr)
for error in errors:
print(f"- {error}", file=sys.stderr)
raise SystemExit(1)
print("Telemetry env validation passed.")
PY
- name: Build Tauri app (with signing + notarization)
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}

View File

@@ -170,6 +170,63 @@ jobs:
security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV"
- name: Validate telemetry env
env:
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
run: |
python3 <<'PY'
import os
import sys
from urllib.parse import urlparse
def normalize(name: str) -> str:
value = os.getenv(name, "").strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
value = value[1:-1].strip()
return value
def normalize_http_like(value: str) -> str:
if "://" in value:
return value
return f"https://{value}"
def is_http_url(value: str) -> bool:
parsed = urlparse(normalize_http_like(value))
return parsed.scheme in {"http", "https"} and bool(parsed.netloc)
values = {
name: normalize(name)
for name in (
"VITE_SENTRY_DSN",
"SENTRY_DSN",
"VITE_POSTHOG_KEY",
"VITE_POSTHOG_HOST",
)
}
errors = []
for name in ("VITE_SENTRY_DSN", "SENTRY_DSN", "VITE_POSTHOG_HOST"):
value = values[name]
if not value:
errors.append(f"{name} must be set for release builds")
elif not is_http_url(value):
errors.append(f"{name} must be a valid http(s) URL")
if not values["VITE_POSTHOG_KEY"]:
errors.append("VITE_POSTHOG_KEY must be set for release builds")
if errors:
print("Telemetry env validation failed:", file=sys.stderr)
for error in errors:
print(f"- {error}", file=sys.stderr)
raise SystemExit(1)
print("Telemetry env validation passed.")
PY
- name: Build Tauri app (with signing + notarization)
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}

View File

@@ -0,0 +1,33 @@
---
type: ADR
id: "0070"
title: "Starter vaults are local-first with explicit remote connection"
status: active
date: 2026-04-19
---
## Context
ADR-0046 moved the Getting Started vault to a public GitHub repo cloned at runtime, and ADR-0059 established that Tolaria should support valid local-only vaults without treating a missing remote as an error.
That still left one mismatch: a freshly cloned starter vault inherited the template repo's `origin` remote. New users therefore landed in a vault that looked remote-backed by default, even though the intended workflow was to explore locally first and only connect a personal remote later. Keeping the starter remote also risked accidental pushes to the public template repo and gave Tolaria no safe place to reject incompatible remotes before tracking started.
## Decision
**After cloning the public starter vault, Tolaria removes every configured git remote so the vault opens local-only by default.** Users connect a remote later through an explicit Add Remote flow exposed from the `No remote` status-bar chip and the command palette.
**The new `git_add_remote` backend is the only path for attaching a remote to an existing local-only vault.** It adds `origin`, fetches the remote, rejects incompatible or ahead histories, and only starts tracking when the remote is safe for the current local repo.
## Options considered
- **Strip starter-vault remotes and add an explicit connect flow** (chosen): preserves a local-first onboarding experience, matches ADR-0059's local-only model, and prevents accidental coupling to the public template repo. Cons: users who want sync must do one extra explicit step.
- **Keep the starter repo's remote attached**: simplest implementation, but it makes the template repo look like the user's real sync target and increases the risk of accidental pushes or confusing remote state.
- **Force remote replacement during onboarding**: guarantees a personal remote up front, but adds too much setup friction to the Getting Started path and weakens Tolaria's offline/local-first story.
## Consequences
- Fresh Getting Started vaults now behave like any other local-only vault: commit locally first, then opt into sync later.
- The app gains a dedicated Add Remote UX (`AddRemoteModal`) plus a backend connection path (`git_add_remote`) instead of overloading clone or commit flows.
- Remote attachment is safer: Tolaria can reject unrelated or incompatible histories before the vault starts tracking a remote.
- The starter repo remains a distribution source only, not an ongoing sync destination.
- Re-evaluate if Tolaria later needs a faster "publish this local starter vault to my own repo" flow that should prefill or streamline the Add Remote step.

View File

@@ -125,3 +125,4 @@ proposed → active → superseded
| [0067](0067-autogit-idle-and-inactive-checkpoints.md) | AutoGit idle and inactive checkpoints | active |
| [0068](0068-h1-only-title-surface-with-optional-untitled-auto-rename.md) | H1-only title surface with optional untitled auto-rename | active |
| [0069](0069-neighborhood-mode-for-note-list-relationship-browsing.md) | Neighborhood mode for note-list relationship browsing | active |
| [0070](0070-starter-vaults-local-first-with-explicit-remote-connection.md) | Starter vaults are local-first with explicit remote connection | active |

View File

@@ -11,6 +11,39 @@ fn scrub_paths(input: &str) -> String {
re.replace_all(input, "<redacted-path>").to_string()
}
fn normalize_embedded_env(raw: Option<&str>) -> Option<String> {
let value = raw?.trim();
if value.is_empty() {
return None;
}
let unwrapped = match (value.chars().next(), value.chars().last()) {
(Some('"'), Some('"')) | (Some('\''), Some('\'')) if value.len() >= 2 => {
value[1..value.len() - 1].trim()
}
_ => value,
};
if unwrapped.is_empty() {
None
} else {
Some(unwrapped.to_string())
}
}
fn normalize_http_like_value(value: &str) -> String {
if value.contains("://") {
value.to_string()
} else {
format!("https://{value}")
}
}
fn parse_embedded_sentry_dsn(raw: Option<&str>) -> Option<sentry::types::Dsn> {
let normalized = normalize_embedded_env(raw)?;
normalize_http_like_value(&normalized).parse().ok()
}
/// Initialize Sentry if the user has opted in to crash reporting.
/// Returns `true` if Sentry was initialized, `false` if skipped.
pub fn init_sentry_from_settings() -> bool {
@@ -23,14 +56,13 @@ pub fn init_sentry_from_settings() -> bool {
return false;
}
let dsn = option_env!("SENTRY_DSN").unwrap_or_default();
if dsn.is_empty() {
let Some(dsn) = parse_embedded_sentry_dsn(option_env!("SENTRY_DSN")) else {
return false;
}
};
let anonymous_id = settings.anonymous_id.unwrap_or_default();
let guard = sentry::init(sentry::ClientOptions {
dsn: dsn.parse().ok(),
dsn: Some(dsn),
release: Some(env!("CARGO_PKG_VERSION").into()),
send_default_pii: false,
before_send: Some(std::sync::Arc::new(|mut event| {
@@ -86,6 +118,43 @@ mod tests {
assert_eq!(scrub_paths("Normal error message"), "Normal error message");
}
#[test]
fn test_normalize_embedded_env_trims_wrapping_quotes() {
assert_eq!(
normalize_embedded_env(Some(" \"value\" ")).as_deref(),
Some("value")
);
assert_eq!(
normalize_embedded_env(Some(" 'value' ")).as_deref(),
Some("value")
);
}
#[test]
fn test_normalize_embedded_env_drops_blank_values() {
assert_eq!(normalize_embedded_env(Some(" ")), None);
assert_eq!(normalize_embedded_env(None), None);
}
#[test]
fn test_parse_embedded_sentry_dsn_accepts_valid_trimmed_value() {
let parsed =
parse_embedded_sentry_dsn(Some(" \"https://public@example.ingest.sentry.io/1\" "));
assert!(parsed.is_some());
}
#[test]
fn test_parse_embedded_sentry_dsn_accepts_scheme_less_value() {
let parsed = parse_embedded_sentry_dsn(Some("public@example.ingest.sentry.io/1"));
assert!(parsed.is_some());
}
#[test]
fn test_parse_embedded_sentry_dsn_rejects_invalid_value() {
let parsed = parse_embedded_sentry_dsn(Some("not a dsn"));
assert!(parsed.is_none());
}
#[test]
fn test_init_sentry_returns_false_without_dsn() {
// Without SENTRY_DSN env var set at compile time, init should return false

View File

@@ -570,6 +570,7 @@ describe('App', () => {
})
it('defaults to All Notes when explicit organization is disabled in vault config', async () => {
localStorage.setItem('tolaria_welcome_dismissed', '1')
mockCommandResults.load_vault_list = {
vaults: [{ label: 'Getting Started', path: '/Users/mock/Documents/Getting Started' }],
active_vault: '/Users/mock/Documents/Getting Started',

View File

@@ -858,7 +858,8 @@ function App() {
setToastMessage,
})
const gitHistory = useGitHistory(notes.activeTabPath, vault.loadGitHistory)
const shouldLoadGitHistory = !layout.inspectorCollapsed && !showAIChat
const gitHistory = useGitHistory(notes.activeTabPath, vault.loadGitHistory, shouldLoadGitHistory)
const handleCreateType = useCallback((name: string) => {
notes.handleCreateType(name)

View File

@@ -0,0 +1,10 @@
<svg width="266" height="363" viewBox="0 0 266 363" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_111_151)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M119.524 5.31219C126.607 -1.77073 138.117 -1.77073 145.2 5.31219C178.844 40.7268 265.61 147.856 265.61 230.195C265.61 303.68 206.29 363 132.805 363C59.3195 363 0 303.68 0 230.195C0 147.856 86.7659 40.7268 119.524 5.31219ZM59.5947 242.88C58.1619 234.744 50.3952 229.074 42.2195 230.195C34.0438 231.316 28.5932 238.799 30.0259 246.935C37.6847 290.425 72.2734 325.622 116.094 334.485C117.821 334.836 119.552 334.864 121.178 334.641C127.269 333.806 132.289 329.26 133.371 322.934C134.756 314.893 129.25 307.054 121.086 305.401C89.7767 299.057 65.0615 273.923 59.5947 242.88Z" fill="#155DFF"/>
</g>
<defs>
<clipPath id="clip0_111_151">
<rect width="266" height="363" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 889 B

View File

@@ -77,6 +77,12 @@ describe('AiPanel', () => {
expect(onClose).toHaveBeenCalled()
})
it('starts a new AI chat when the header action is clicked', () => {
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
fireEvent.click(screen.getByTitle('New AI chat'))
expect(mockClearConversation).toHaveBeenCalledOnce()
})
it('renders empty state without context', () => {
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
expect(screen.getByText('Open a note, then ask the AI about it')).toBeTruthy()
@@ -153,6 +159,20 @@ describe('AiPanel', () => {
vi.useRealTimers()
})
it('focuses the panel shell when reopening with existing messages', async () => {
vi.useFakeTimers()
mockMessages = [{
userMessage: 'Remember this',
actions: [],
response: 'Still here.',
id: 'msg-3',
}]
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
await act(() => { vi.advanceTimersByTime(1) })
expect(document.activeElement).toBe(screen.getByTestId('ai-panel'))
vi.useRealTimers()
})
it('calls onClose when Escape is pressed while panel has focus', async () => {
vi.useFakeTimers()
const onClose = vi.fn()

View File

@@ -8,7 +8,7 @@ import {
import { DEFAULT_AI_AGENT, getAiAgentDefinition, type AiAgentId } from '../lib/aiAgents'
import { type NoteListItem } from '../utils/ai-context'
import type { VaultEntry } from '../types'
import { useAiPanelController } from './useAiPanelController'
import { useAiPanelController, type AiPanelController } from './useAiPanelController'
import { useAiPanelPromptQueue } from './useAiPanelPromptQueue'
import { useAiPanelFocus } from './useAiPanelFocus'
@@ -32,29 +32,31 @@ interface AiPanelProps {
noteListFilter?: { type: string | null; query: string }
}
export function AiPanel({
interface AiPanelViewProps {
controller: AiPanelController
onClose: () => void
onOpenNote?: (path: string) => void
defaultAiAgent?: AiAgentId
defaultAiAgentReady?: boolean
activeEntry?: VaultEntry | null
entries?: VaultEntry[]
}
export function AiPanelView({
controller,
onClose,
onOpenNote,
defaultAiAgent: providedDefaultAiAgent,
defaultAiAgentReady: providedDefaultAiAgentReady,
onFileCreated,
onFileModified,
onVaultChanged,
vaultPath,
activeEntry,
activeNoteContent,
entries,
openTabs,
noteList,
noteListFilter,
}: AiPanelProps) {
}: AiPanelViewProps) {
const defaultAiAgent = providedDefaultAiAgent ?? DEFAULT_AI_AGENT
const defaultAiAgentReady = providedDefaultAiAgentReady ?? true
const useLegacyAiExperience = providedDefaultAiAgent === undefined && providedDefaultAiAgentReady === undefined
const inputRef = useRef<HTMLDivElement>(null)
const panelRef = useRef<HTMLElement>(null)
const agentLabel = getAiAgentDefinition(defaultAiAgent).label
const {
agent,
input,
@@ -64,24 +66,17 @@ export function AiPanel({
isActive,
handleSend,
handleNavigateWikilink,
} = useAiPanelController({
vaultPath,
defaultAiAgent,
defaultAiAgentReady,
activeEntry,
activeNoteContent,
entries,
openTabs,
noteList,
noteListFilter,
onOpenNote,
onFileCreated,
onFileModified,
onVaultChanged,
})
handleNewChat,
} = controller
useAiPanelPromptQueue({ agent, input, isActive, setInput })
useAiPanelFocus({ inputRef, panelRef, isActive, onClose })
useAiPanelFocus({
inputRef,
panelRef,
hasMessages: agent.messages.length > 0,
isActive,
onClose,
})
return (
<aside
@@ -99,7 +94,13 @@ export function AiPanel({
data-testid="ai-panel"
data-ai-active={isActive || undefined}
>
<AiPanelHeader agentLabel={agentLabel} agentReady={defaultAiAgentReady} legacyCopy={useLegacyAiExperience} onClose={onClose} onClear={agent.clearConversation} />
<AiPanelHeader
agentLabel={agentLabel}
agentReady={defaultAiAgentReady}
legacyCopy={useLegacyAiExperience}
onClose={onClose}
onNewChat={handleNewChat}
/>
{activeEntry && (
<AiPanelContextBar activeEntry={activeEntry} linkedCount={linkedEntries.length} />
)}
@@ -128,3 +129,48 @@ export function AiPanel({
</aside>
)
}
export function AiPanel({
onClose,
onOpenNote,
defaultAiAgent: providedDefaultAiAgent,
defaultAiAgentReady: providedDefaultAiAgentReady,
onFileCreated,
onFileModified,
onVaultChanged,
vaultPath,
activeEntry,
activeNoteContent,
entries,
openTabs,
noteList,
noteListFilter,
}: AiPanelProps) {
const controller = useAiPanelController({
vaultPath,
defaultAiAgent: providedDefaultAiAgent ?? DEFAULT_AI_AGENT,
defaultAiAgentReady: providedDefaultAiAgentReady ?? true,
activeEntry,
activeNoteContent,
entries,
openTabs,
noteList,
noteListFilter,
onOpenNote,
onFileCreated,
onFileModified,
onVaultChanged,
})
return (
<AiPanelView
controller={controller}
onClose={onClose}
onOpenNote={onOpenNote}
defaultAiAgent={providedDefaultAiAgent}
defaultAiAgentReady={providedDefaultAiAgentReady}
activeEntry={activeEntry}
entries={entries}
/>
)
}

View File

@@ -12,7 +12,7 @@ interface AiPanelHeaderProps {
agentReady: boolean
legacyCopy: boolean
onClose: () => void
onClear: () => void
onNewChat: () => void
}
interface AiPanelContextBarProps {
@@ -111,7 +111,7 @@ export function AiPanelHeader({
agentReady,
legacyCopy,
onClose,
onClear,
onNewChat,
}: AiPanelHeaderProps) {
return (
<div
@@ -132,8 +132,9 @@ export function AiPanelHeader({
</div>
<button
className="shrink-0 border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
onClick={onClear}
title="New conversation"
onClick={onNewChat}
aria-label="New AI chat"
title="New AI chat"
>
<Plus size={16} />
</button>

View File

@@ -1,8 +1,11 @@
import { useEffect } from 'react'
import { DEFAULT_AI_AGENT, type AiAgentId } from '../lib/aiAgents'
import type { VaultEntry, GitCommit } from '../types'
import type { NoteListItem } from '../utils/ai-context'
import { Inspector, type FrontmatterValue } from './Inspector'
import { AiPanel } from './AiPanel'
import { AiPanelView } from './AiPanel'
import { useAiPanelController } from './useAiPanelController'
import { NEW_AI_CHAT_EVENT } from '../utils/aiPromptBridge'
interface EditorRightPanelProps {
showAIChat?: boolean
@@ -43,26 +46,45 @@ export function EditorRightPanel({
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateMissingType, onCreateAndOpenNote, onInitializeProperties, onToggleRawEditor, onOpenNote,
onFileCreated, onFileModified, onVaultChanged,
}: EditorRightPanelProps) {
const aiPanelController = useAiPanelController({
vaultPath,
defaultAiAgent,
defaultAiAgentReady,
activeEntry: inspectorEntry,
activeNoteContent: inspectorContent,
entries,
noteList,
noteListFilter,
onOpenNote,
onFileCreated,
onFileModified,
onVaultChanged,
})
const { handleNewChat } = aiPanelController
useEffect(() => {
const handleRequestedNewChat = () => {
handleNewChat()
}
window.addEventListener(NEW_AI_CHAT_EVENT, handleRequestedNewChat)
return () => window.removeEventListener(NEW_AI_CHAT_EVENT, handleRequestedNewChat)
}, [handleNewChat])
if (showAIChat) {
return (
<div
className="shrink-0 flex flex-col min-h-0"
style={{ width: inspectorWidth, minWidth: 240, height: '100%' }}
>
<AiPanel
<AiPanelView
controller={aiPanelController}
onClose={() => onToggleAIChat?.()}
onOpenNote={onOpenNote}
defaultAiAgent={defaultAiAgent}
defaultAiAgentReady={defaultAiAgentReady}
onFileCreated={onFileCreated}
onFileModified={onFileModified}
onVaultChanged={onVaultChanged}
vaultPath={vaultPath}
activeEntry={inspectorEntry}
activeNoteContent={inspectorContent}
entries={entries}
noteList={noteList}
noteListFilter={noteListFilter}
/>
</div>
)

View File

@@ -1,6 +1,7 @@
import { render, screen, fireEvent } from '@testing-library/react'
import { beforeEach, describe, it, expect, vi } from 'vitest'
import { WelcomeScreen } from './WelcomeScreen'
import tolariaIcon from '@/assets/tolaria-icon.svg'
const dragRegionMouseDown = vi.fn()
@@ -30,19 +31,28 @@ describe('WelcomeScreen', () => {
it('renders welcome title and subtitle', () => {
render(<WelcomeScreen {...defaultProps} />)
expect(screen.getByText('Welcome to Tolaria')).toBeInTheDocument()
expect(screen.getByText(/Wiki-linked knowledge management/)).toBeInTheDocument()
expect(screen.getByText('Markdown knowledge management for the age of AI')).toBeInTheDocument()
})
it('shows all three option buttons', () => {
it('renders the local Tolaria branding icon', () => {
render(<WelcomeScreen {...defaultProps} />)
expect(screen.getByTestId('welcome-create-new')).toHaveTextContent('Create empty vault')
expect(screen.getByTestId('welcome-open-folder')).toHaveTextContent('Open existing vault')
expect(screen.getByTestId('welcome-create-vault')).toHaveTextContent('Get started with a template')
const brandIcon = screen.getByAltText('Tolaria icon')
expect(brandIcon).toHaveAttribute('src', tolariaIcon)
})
it('shows the onboarding actions in the guided-first order', () => {
render(<WelcomeScreen {...defaultProps} />)
const optionButtons = screen.getAllByRole('button')
expect(optionButtons[0]).toBe(screen.getByTestId('welcome-create-vault'))
expect(optionButtons[1]).toBe(screen.getByTestId('welcome-create-new'))
expect(optionButtons[2]).toBe(screen.getByTestId('welcome-open-folder'))
})
it('focuses the first action for keyboard users', () => {
render(<WelcomeScreen {...defaultProps} />)
expect(screen.getByTestId('welcome-create-new')).toHaveFocus()
expect(screen.getByTestId('welcome-create-vault')).toHaveFocus()
})
it('shows the simplified template option description', () => {
@@ -101,13 +111,13 @@ describe('WelcomeScreen', () => {
})
it('cycles onboarding actions with Tab and activates the selected action with Enter', () => {
const onOpenFolder = vi.fn()
render(<WelcomeScreen {...defaultProps} onOpenFolder={onOpenFolder} />)
const onCreateEmptyVault = vi.fn()
render(<WelcomeScreen {...defaultProps} onCreateEmptyVault={onCreateEmptyVault} />)
fireEvent.keyDown(window, { key: 'Tab' })
fireEvent.keyDown(window, { key: 'Enter' })
expect(onOpenFolder).toHaveBeenCalledOnce()
expect(onCreateEmptyVault).toHaveBeenCalledOnce()
})
it('disables all buttons while creating', () => {

View File

@@ -3,6 +3,7 @@ import type { ReactNode } from 'react'
import { FolderOpen, Plus, AlertTriangle, Loader2, Rocket } from 'lucide-react'
import { OnboardingShell } from './OnboardingShell'
import { Button } from '@/components/ui/button'
import tolariaIcon from '@/assets/tolaria-icon.svg'
interface WelcomeScreenProps {
mode: 'welcome' | 'vault-missing'
@@ -105,6 +106,12 @@ const ICON_WRAP_STYLE: React.CSSProperties = {
justifyContent: 'center',
}
const BRAND_ICON_STYLE: React.CSSProperties = {
width: 64,
height: 64,
display: 'block',
}
const TITLE_STYLE: React.CSSProperties = {
fontSize: 28,
fontWeight: 700,
@@ -267,10 +274,10 @@ function getWelcomeScreenPresentation(
): WelcomeScreenPresentation {
if (mode === 'welcome') {
return {
heroBackground: 'var(--accent-blue-light, #EBF4FF)',
heroIcon: <span style={{ fontSize: 28, color: 'var(--accent-blue)' }}>&#10022;</span>,
heroBackground: 'transparent',
heroIcon: <img src={tolariaIcon} alt="Tolaria icon" style={BRAND_ICON_STYLE} />,
openFolderLabel: 'Open existing vault',
subtitle: 'Wiki-linked knowledge management for deep thinkers.\nChoose how to get started.',
subtitle: 'Markdown knowledge management for the age of AI',
templateDescription: isOffline
? `Requires internet — clone later. Suggested path: ${defaultVaultPath}`
: 'Download the getting started vault',
@@ -303,18 +310,18 @@ function useWelcomeActionButtons({
> & {
busy: boolean
}) {
const primaryActionRef = useRef<HTMLButtonElement>(null)
const openFolderActionRef = useRef<HTMLButtonElement>(null)
const templateActionRef = useRef<HTMLButtonElement>(null)
const createEmptyActionRef = useRef<HTMLButtonElement>(null)
const openFolderActionRef = useRef<HTMLButtonElement>(null)
const actionButtonRefs = useMemo(
() => [primaryActionRef, openFolderActionRef, templateActionRef],
() => [templateActionRef, createEmptyActionRef, openFolderActionRef],
[],
)
const actions = useMemo<WelcomeAction[]>(
() => ([
{ disabled: isOffline, run: onCreateVault },
{ disabled: false, run: onCreateEmptyVault },
{ disabled: false, run: onOpenFolder },
{ disabled: isOffline, run: onCreateVault },
]),
[isOffline, onCreateEmptyVault, onCreateVault, onOpenFolder],
)
@@ -323,7 +330,7 @@ function useWelcomeActionButtons({
if (busy) return
// WKWebView can ignore `autoFocus`; move focus explicitly so keyboard-only
// onboarding always starts on "Create empty vault".
// onboarding always starts on the guided template flow.
focusWelcomeAction(actionButtonRefs, 0)
}, [actionButtonRefs, busy, mode])
@@ -355,9 +362,9 @@ function useWelcomeActionButtons({
}, [actionButtonRefs, actions, busy])
return {
primaryActionRef,
openFolderActionRef,
templateActionRef,
createEmptyActionRef,
openFolderActionRef,
}
}
@@ -375,7 +382,7 @@ export function WelcomeScreen({
}: WelcomeScreenProps) {
const busy = creatingAction !== null
const presentation = getWelcomeScreenPresentation(mode, defaultVaultPath, isOffline)
const { primaryActionRef, openFolderActionRef, templateActionRef } = useWelcomeActionButtons({
const { templateActionRef, createEmptyActionRef, openFolderActionRef } = useWelcomeActionButtons({
mode,
busy,
isOffline,
@@ -410,6 +417,21 @@ export function WelcomeScreen({
<div style={DIVIDER_STYLE} />
<div style={{ width: '100%', display: 'flex', flexDirection: 'column', gap: 10 }}>
<OptionButton
icon={<Rocket size={18} style={{ color: 'var(--accent-purple)' }} />}
iconBg="var(--accent-purple-light, #F3E8FF)"
label="Get started with a template"
description={presentation.templateDescription}
loadingLabel="Downloading template…"
loadingDescription="Cloning the Getting Started vault template"
onClick={onCreateVault}
disabled={busy || isOffline}
loading={creatingAction === 'template'}
testId="welcome-create-vault"
autoFocus
buttonRef={templateActionRef}
/>
<OptionButton
icon={<Plus size={18} style={{ color: 'var(--accent-blue)' }} />}
iconBg="var(--accent-blue-light, #EBF4FF)"
@@ -421,8 +443,7 @@ export function WelcomeScreen({
disabled={busy}
loading={creatingAction === 'empty'}
testId="welcome-create-new"
autoFocus
buttonRef={primaryActionRef}
buttonRef={createEmptyActionRef}
/>
<OptionButton
@@ -435,20 +456,6 @@ export function WelcomeScreen({
testId="welcome-open-folder"
buttonRef={openFolderActionRef}
/>
<OptionButton
icon={<Rocket size={18} style={{ color: 'var(--accent-purple)' }} />}
iconBg="var(--accent-purple-light, #F3E8FF)"
label="Get started with a template"
description={presentation.templateDescription}
loadingLabel="Downloading template…"
loadingDescription="Cloning the Getting Started vault template"
onClick={onCreateVault}
disabled={busy || isOffline}
loading={creatingAction === 'template'}
testId="welcome-create-vault"
buttonRef={templateActionRef}
/>
</div>
{creatingAction === 'template' && (

View File

@@ -24,6 +24,18 @@ interface UseAiPanelControllerArgs {
onVaultChanged?: () => void
}
export interface AiPanelController {
agent: ReturnType<typeof useCliAiAgent>
input: string
setInput: React.Dispatch<React.SetStateAction<string>>
linkedEntries: ReturnType<typeof useAiPanelContextSnapshot>['linkedEntries']
hasContext: boolean
isActive: boolean
handleSend: (text: string, references: NoteReference[]) => void
handleNavigateWikilink: (target: string) => void
handleNewChat: () => void
}
export function useAiPanelController({
vaultPath,
defaultAiAgent,
@@ -38,7 +50,7 @@ export function useAiPanelController({
onFileCreated,
onFileModified,
onVaultChanged,
}: UseAiPanelControllerArgs) {
}: UseAiPanelControllerArgs): AiPanelController {
const [input, setInput] = useState('')
const { linkedEntries, contextPrompt } = useAiPanelContextSnapshot({
activeEntry,
@@ -73,6 +85,11 @@ export function useAiPanelController({
onOpenNote?.(target)
}, [onOpenNote])
const handleNewChat = useCallback(() => {
agent.clearConversation()
setInput('')
}, [agent])
return {
agent,
input,
@@ -82,5 +99,6 @@ export function useAiPanelController({
isActive,
handleSend,
handleNavigateWikilink,
handleNewChat,
}
}

View File

@@ -3,33 +3,53 @@ import { useCallback, useEffect } from 'react'
interface UseAiPanelFocusArgs {
inputRef: React.RefObject<HTMLDivElement | null>
panelRef: React.RefObject<HTMLElement | null>
hasMessages: boolean
isActive: boolean
onClose: () => void
}
function focusPreferredElement(
panelRef: React.RefObject<HTMLElement | null>,
inputRef: React.RefObject<HTMLDivElement | null>,
shouldFocusPanel: boolean,
) {
if (shouldFocusPanel) {
panelRef.current?.focus()
return
}
inputRef.current?.focus()
}
function shouldHandleEscape(
event: KeyboardEvent,
panelRef: React.RefObject<HTMLElement | null>,
): boolean {
return event.key === 'Escape' && !!panelRef.current?.contains(document.activeElement)
}
export function useAiPanelFocus({
inputRef,
panelRef,
hasMessages,
isActive,
onClose,
}: UseAiPanelFocusArgs) {
const shouldFocusPanel = hasMessages || isActive
useEffect(() => {
const timer = setTimeout(() => inputRef.current?.focus(), 0)
const timer = setTimeout(() => {
focusPreferredElement(panelRef, inputRef, shouldFocusPanel)
}, 0)
return () => clearTimeout(timer)
}, [inputRef])
}, [inputRef, panelRef, shouldFocusPanel])
useEffect(() => {
if (isActive) {
panelRef.current?.focus()
return
}
inputRef.current?.focus()
}, [inputRef, isActive, panelRef])
focusPreferredElement(panelRef, inputRef, shouldFocusPanel)
}, [inputRef, panelRef, shouldFocusPanel])
const handleEscape = useCallback((event: KeyboardEvent) => {
if (event.key !== 'Escape') return
if (!panelRef.current?.contains(document.activeElement)) return
if (!shouldHandleEscape(event, panelRef)) return
event.preventDefault()
onClose()

View File

@@ -1,5 +1,6 @@
import type { CommandAction } from './types'
import type { ViewMode } from '../useViewMode'
import { requestNewAiChat } from '../../utils/aiPromptBridge'
interface ViewCommandsConfig {
hasActiveNote: boolean
@@ -34,6 +35,7 @@ export function buildViewCommands(config: ViewCommandsConfig): CommandAction[] {
{ id: 'toggle-diff', label: 'Toggle Diff Mode', group: 'View', keywords: ['diff', 'changes', 'git', 'compare', 'version'], enabled: hasActiveNote && activeNoteModified, execute: () => onToggleDiff?.() },
{ id: 'toggle-raw-editor', label: 'Toggle Raw Editor', group: 'View', keywords: ['raw', 'source', 'markdown', 'frontmatter', 'code', 'textarea'], enabled: hasActiveNote && !!onToggleRawEditor, execute: () => onToggleRawEditor?.() },
{ id: 'toggle-ai-panel', label: 'Toggle AI Panel', group: 'View', shortcut: '⇧⌘L', keywords: ['ai', 'agent', 'chat', 'assistant', 'contextual'], enabled: true, execute: () => onToggleAIChat?.() },
{ id: 'new-ai-chat', label: 'New AI chat', group: 'View', keywords: ['ai', 'agent', 'chat', 'assistant', 'new', 'fresh', 'conversation', 'reset'], enabled: true, execute: requestNewAiChat },
{ id: 'toggle-backlinks', label: 'Toggle Backlinks', group: 'View', keywords: ['backlinks', 'references', 'links', 'mentions', 'incoming'], enabled: hasActiveNote, execute: onToggleInspector },
{ id: 'customize-note-list-columns', label: noteListColumnsLabel, group: 'View', keywords: ['all notes', 'inbox', 'columns', 'chips', 'properties', 'note list'], enabled: !!(canCustomizeNoteListColumns && onCustomizeNoteListColumns), execute: () => onCustomizeNoteListColumns?.() },
{ id: 'zoom-in', label: `Zoom In (${zoomLevel}%)`, group: 'View', shortcut: '⌘=', keywords: ['zoom', 'bigger', 'larger', 'scale'], enabled: zoomLevel < 150, execute: onZoomIn },

View File

@@ -2,6 +2,7 @@ import { describe, it, expect, vi } from 'vitest'
import { renderHook } from '@testing-library/react'
import { useCommandRegistry, buildTypeCommands, extractVaultTypes, pluralizeType, groupSortKey } from './useCommandRegistry'
import type { CommandAction } from './useCommandRegistry'
import { NEW_AI_CHAT_EVENT, OPEN_AI_CHAT_EVENT } from '../utils/aiPromptBridge'
function makeConfig(overrides: Record<string, unknown> = {}) {
return {
@@ -220,6 +221,24 @@ describe('useCommandRegistry', () => {
expect(findCommand(result.current, 'toggle-raw-editor')?.enabled).toBe(false)
})
it('includes a New AI chat command that opens and resets the panel session', () => {
const config = makeConfig()
const dispatchSpy = vi.spyOn(window, 'dispatchEvent')
const { result } = renderHook(() => useCommandRegistry(config))
const cmd = findCommand(result.current, 'new-ai-chat')
expect(cmd).toBeDefined()
expect(cmd!.group).toBe('View')
expect(cmd!.label).toBe('New AI chat')
expect(cmd!.enabled).toBe(true)
cmd!.execute()
expect(dispatchSpy).toHaveBeenCalledWith(expect.objectContaining({ type: NEW_AI_CHAT_EVENT }))
expect(dispatchSpy).toHaveBeenCalledWith(expect.objectContaining({ type: OPEN_AI_CHAT_EVENT }))
dispatchSpy.mockRestore()
})
it('omits Inbox navigation when the explicit workflow is disabled', () => {
const config = makeConfig({ showInbox: false })
const { result } = renderHook(() => useCommandRegistry(config))

View File

@@ -0,0 +1,105 @@
import { renderHook, act } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { GitCommit } from '../types'
import { useGitHistory } from './useGitHistory'
const mockHistory: GitCommit[] = [
{ hash: 'abc', shortHash: 'abc', author: 'luca', date: 1_700_000_000, message: 'Initial commit' },
]
describe('useGitHistory', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('waits briefly before loading note history', async () => {
const loadGitHistory = vi.fn().mockResolvedValue(mockHistory)
const { result } = renderHook(() => useGitHistory('/vault/a.md', loadGitHistory, true))
expect(result.current).toEqual([])
expect(loadGitHistory).not.toHaveBeenCalled()
await act(async () => {
await vi.advanceTimersByTimeAsync(199)
})
expect(loadGitHistory).not.toHaveBeenCalled()
await act(async () => {
await vi.advanceTimersByTimeAsync(1)
})
expect(loadGitHistory).toHaveBeenCalledWith('/vault/a.md')
expect(result.current).toEqual(mockHistory)
})
it('skips loading when history is disabled', () => {
const loadGitHistory = vi.fn().mockResolvedValue(mockHistory)
const { result } = renderHook(() => useGitHistory('/vault/a.md', loadGitHistory, false))
act(() => {
vi.advanceTimersByTime(1_000)
})
expect(loadGitHistory).not.toHaveBeenCalled()
expect(result.current).toEqual([])
})
it('cancels stale pending loads when the active note changes quickly', async () => {
const loadGitHistory = vi.fn((path: string) => Promise.resolve([
{ ...mockHistory[0], hash: path, shortHash: path, message: path },
]))
const { result, rerender } = renderHook(
({ path }) => useGitHistory(path, loadGitHistory, true),
{ initialProps: { path: '/vault/a.md' as string | null } },
)
await act(async () => {
await vi.advanceTimersByTimeAsync(100)
})
rerender({ path: '/vault/b.md' })
await act(async () => {
await vi.advanceTimersByTimeAsync(199)
})
expect(loadGitHistory).not.toHaveBeenCalled()
await act(async () => {
await vi.advanceTimersByTimeAsync(1)
})
expect(loadGitHistory).toHaveBeenCalledTimes(1)
expect(loadGitHistory).toHaveBeenCalledWith('/vault/b.md')
expect(result.current).toEqual([
expect.objectContaining({ hash: '/vault/b.md', message: '/vault/b.md' }),
])
})
it('clears previously loaded history when the inspector is hidden', async () => {
const loadGitHistory = vi.fn().mockResolvedValue(mockHistory)
const { result, rerender } = renderHook(
({ enabled }) => useGitHistory('/vault/a.md', loadGitHistory, enabled),
{ initialProps: { enabled: true } },
)
await act(async () => {
await vi.advanceTimersByTimeAsync(200)
})
expect(result.current).toEqual(mockHistory)
rerender({ enabled: false })
expect(result.current).toEqual([])
})
})

View File

@@ -1,13 +1,43 @@
import { useEffect, useState } from 'react'
import type { GitCommit } from '../types'
export function useGitHistory(activeTabPath: string | null, loadGitHistory: (path: string) => Promise<GitCommit[]>) {
const [gitHistory, setGitHistory] = useState<GitCommit[]>([])
const GIT_HISTORY_LOAD_DELAY_MS = 200
export function useGitHistory(
activeTabPath: string | null,
loadGitHistory: (path: string) => Promise<GitCommit[]>,
enabled = true,
) {
const [loadedHistory, setLoadedHistory] = useState<{
path: string | null
commits: GitCommit[]
}>({
path: null,
commits: [],
})
useEffect(() => {
if (!activeTabPath) return
loadGitHistory(activeTabPath).then(setGitHistory)
}, [activeTabPath, loadGitHistory])
if (!enabled || !activeTabPath) return
return activeTabPath ? gitHistory : []
let cancelled = false
const timeoutId = window.setTimeout(() => {
void loadGitHistory(activeTabPath).then((history) => {
if (cancelled) return
setLoadedHistory({
path: activeTabPath,
commits: history,
})
})
}, GIT_HISTORY_LOAD_DELAY_MS)
return () => {
cancelled = true
window.clearTimeout(timeoutId)
}
}, [activeTabPath, enabled, loadGitHistory])
return enabled && activeTabPath && loadedHistory.path === activeTabPath
? loadedHistory.commits
: []
}

View File

@@ -195,6 +195,55 @@ describe('useVaultSwitcher', () => {
expect(result.current.isGettingStartedHidden).toBe(false)
})
it('drops stale canonical Getting Started entries when the starter path is missing', async () => {
mockVaultListStore = {
vaults: [
{ label: 'Getting Started', path: expectedDefaultVaultPath },
{ label: 'Work', path: '/work/vault' },
],
active_vault: '/work/vault',
hidden_defaults: [],
}
setMockInvokeBehavior({
checkVaultExists: ({ path }) => path === '/work/vault',
})
const { result } = await renderLoadedVaultSwitcher()
expect(result.current.allVaults).toEqual([{ label: 'Work', path: '/work/vault', available: true }])
expect(result.current.vaultPath).toBe('/work/vault')
await waitFor(() => {
expect(mockVaultListStore).toEqual({
vaults: [{ label: 'Work', path: '/work/vault' }],
active_vault: '/work/vault',
hidden_defaults: [],
})
})
})
it('clears a stale canonical Getting Started selection when the starter path is missing', async () => {
mockVaultListStore = {
vaults: [{ label: 'Getting Started', path: expectedDefaultVaultPath }],
active_vault: expectedDefaultVaultPath,
hidden_defaults: [],
}
setMockInvokeBehavior({ checkVaultExists: false })
const { result } = await renderLoadedVaultSwitcher()
expect(result.current.allVaults).toEqual([])
expect(result.current.selectedVaultPath).toBeNull()
await waitFor(() => {
expect(mockVaultListStore).toEqual({
vaults: [],
active_vault: null,
hidden_defaults: [],
})
})
})
it('handles load error gracefully', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
mockInvokeFn.mockImplementation((cmd: string) => {

View File

@@ -137,6 +137,18 @@ function syncDefaultVaultExport(path: string) {
DEFAULT_VAULTS[0] = { label: GETTING_STARTED_LABEL, path }
}
function isCanonicalGettingStartedPath(path: string, resolvedDefaultPath: string): boolean {
return path === resolvedDefaultPath
}
function isUnavailableGettingStartedVault(vault: VaultOption): boolean {
return vault.label === GETTING_STARTED_LABEL && vault.available === false
}
function shouldDropPersistedGettingStartedVault(vault: VaultOption, resolvedDefaultPath: string): boolean {
return isCanonicalGettingStartedPath(vault.path, resolvedDefaultPath) || isUnavailableGettingStartedVault(vault)
}
async function checkVaultAvailability(path: string): Promise<boolean> {
if (!path) {
return false
@@ -166,7 +178,79 @@ async function loadInitialVaultState() {
console.warn('Failed to load vault list:', vaultListResult.reason)
}
return { activeVault, defaultAvailable, hiddenDefaults, resolvedDefaultPath, vaults }
return sanitizeCanonicalGettingStartedState({
activeVault,
defaultAvailable,
hiddenDefaults,
resolvedDefaultPath,
vaults,
})
}
function sanitizeCanonicalGettingStartedState({
activeVault,
defaultAvailable,
hiddenDefaults,
resolvedDefaultPath,
vaults,
}: {
activeVault: string | null
defaultAvailable: boolean
hiddenDefaults: string[]
resolvedDefaultPath: string
vaults: VaultOption[]
}) {
if (!resolvedDefaultPath) {
return { activeVault, defaultAvailable, hiddenDefaults, resolvedDefaultPath, vaults }
}
const filteredVaults = vaults.filter(
(vault) => !shouldDropPersistedGettingStartedVault(vault, resolvedDefaultPath),
)
const removedStarterPaths = new Set(
vaults
.filter((vault) => shouldDropPersistedGettingStartedVault(vault, resolvedDefaultPath))
.map((vault) => vault.path),
)
const sanitizedActiveVault = resolveSanitizedGettingStartedSelection({
activeVault,
defaultAvailable,
filteredVaults,
removedStarterPaths,
resolvedDefaultPath,
})
return {
activeVault: sanitizedActiveVault,
defaultAvailable,
hiddenDefaults,
resolvedDefaultPath,
vaults: filteredVaults,
}
}
function resolveSanitizedGettingStartedSelection({
activeVault,
defaultAvailable,
filteredVaults,
removedStarterPaths,
resolvedDefaultPath,
}: {
activeVault: string | null
defaultAvailable: boolean
filteredVaults: VaultOption[]
removedStarterPaths: Set<string>
resolvedDefaultPath: string
}): string | null {
if (!activeVault || !removedStarterPaths.has(activeVault)) {
return activeVault
}
if (isCanonicalGettingStartedPath(activeVault, resolvedDefaultPath) && defaultAvailable) {
return activeVault
}
return filteredVaults[0]?.path ?? null
}
function buildDefaultVaults({

View File

@@ -1,8 +1,5 @@
import * as Sentry from '@sentry/react'
const SENTRY_DSN = import.meta.env.VITE_SENTRY_DSN ?? ''
const POSTHOG_KEY = import.meta.env.VITE_POSTHOG_KEY ?? ''
const POSTHOG_HOST = import.meta.env.VITE_POSTHOG_HOST ?? 'https://us.i.posthog.com'
import { resolveFrontendTelemetryConfig } from './telemetryConfig'
/** Pattern that matches absolute file paths (macOS / Linux / Windows). */
const PATH_PATTERN = /(?:\/[\w.-]+){2,}|[A-Z]:\\[\w\\.-]+/g
@@ -11,24 +8,30 @@ function scrubPaths(input: string): string {
return input.replace(PATH_PATTERN, '<redacted-path>')
}
function scrubSentryEvent(event: Sentry.ErrorEvent): Sentry.ErrorEvent {
if (event.message) event.message = scrubPaths(event.message)
for (const ex of event.exception?.values ?? []) {
if (ex.value) ex.value = scrubPaths(ex.value)
}
for (const breadcrumb of event.breadcrumbs ?? []) {
if (breadcrumb.message) breadcrumb.message = scrubPaths(breadcrumb.message)
}
return event
}
let sentryInitialized = false
let posthogInstance: typeof import('posthog-js').default | null = null
export function initSentry(anonymousId: string): void {
if (sentryInitialized || !SENTRY_DSN) return
if (sentryInitialized) return
const { sentryDsn } = resolveFrontendTelemetryConfig()
if (!sentryDsn) return
Sentry.init({
dsn: SENTRY_DSN,
dsn: sentryDsn,
sendDefaultPii: false,
beforeSend(event) {
if (event.message) event.message = scrubPaths(event.message)
for (const ex of event.exception?.values ?? []) {
if (ex.value) ex.value = scrubPaths(ex.value)
}
for (const bc of event.breadcrumbs ?? []) {
if (bc.message) bc.message = scrubPaths(bc.message)
}
return event
},
beforeSend: scrubSentryEvent,
})
Sentry.setUser({ id: anonymousId })
sentryInitialized = true
@@ -41,10 +44,14 @@ export function teardownSentry(): void {
}
export async function initPostHog(anonymousId: string, releaseChannel?: string): Promise<void> {
if (posthogInstance || !POSTHOG_KEY) return
if (posthogInstance) return
const { posthogKey, posthogHost } = resolveFrontendTelemetryConfig()
if (!posthogKey || !posthogHost) return
const posthog = (await import('posthog-js')).default
posthog.init(POSTHOG_KEY, {
api_host: POSTHOG_HOST,
posthog.init(posthogKey, {
api_host: posthogHost,
autocapture: false,
capture_pageview: false,
persistence: 'memory',

View File

@@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest'
import {
_defaultPostHogHostForTest as defaultPostHogHost,
resolveFrontendTelemetryConfig,
sanitizeTelemetryEnvValue,
} from './telemetryConfig'
describe('sanitizeTelemetryEnvValue', () => {
it('trims surrounding whitespace', () => {
expect(sanitizeTelemetryEnvValue(' value ')).toBe('value')
})
it('unwraps matching quotes after trimming', () => {
expect(sanitizeTelemetryEnvValue(' "value" ')).toBe('value')
expect(sanitizeTelemetryEnvValue(" 'value' ")).toBe('value')
})
it('returns an empty string for blank input', () => {
expect(sanitizeTelemetryEnvValue(' ')).toBe('')
expect(sanitizeTelemetryEnvValue(undefined)).toBe('')
})
})
describe('resolveFrontendTelemetryConfig', () => {
it('keeps valid telemetry values after sanitizing them', () => {
expect(resolveFrontendTelemetryConfig({
VITE_SENTRY_DSN: ' "https://public@example.ingest.sentry.io/123456" ',
VITE_POSTHOG_KEY: " 'phc_test_key' ",
VITE_POSTHOG_HOST: ' https://eu.i.posthog.com ',
})).toEqual({
sentryDsn: 'https://public@example.ingest.sentry.io/123456',
posthogKey: 'phc_test_key',
posthogHost: 'https://eu.i.posthog.com',
})
})
it('uses the default PostHog host when one is not configured', () => {
expect(resolveFrontendTelemetryConfig({
VITE_SENTRY_DSN: 'https://public@example.ingest.sentry.io/123456',
VITE_POSTHOG_KEY: 'phc_test_key',
}).posthogHost).toBe(defaultPostHogHost)
})
it('adds https to scheme-less DSNs and PostHog hosts', () => {
expect(resolveFrontendTelemetryConfig({
VITE_SENTRY_DSN: 'public@example.ingest.sentry.io/123456',
VITE_POSTHOG_KEY: 'phc_test_key',
VITE_POSTHOG_HOST: 'eu.i.posthog.com',
})).toEqual({
sentryDsn: 'https://public@example.ingest.sentry.io/123456',
posthogKey: 'phc_test_key',
posthogHost: 'https://eu.i.posthog.com',
})
})
it('drops invalid Sentry DSNs instead of passing them to the SDK', () => {
expect(resolveFrontendTelemetryConfig({
VITE_SENTRY_DSN: 'not a dsn',
VITE_POSTHOG_KEY: 'phc_test_key',
VITE_POSTHOG_HOST: 'https://eu.i.posthog.com',
}).sentryDsn).toBe('')
})
it('drops invalid PostHog hosts instead of loading scripts from them', () => {
expect(resolveFrontendTelemetryConfig({
VITE_SENTRY_DSN: 'https://public@example.ingest.sentry.io/123456',
VITE_POSTHOG_KEY: 'phc_test_key',
VITE_POSTHOG_HOST: 'not a url',
}).posthogHost).toBeNull()
})
})

View File

@@ -0,0 +1,75 @@
const DEFAULT_POSTHOG_HOST = 'https://us.i.posthog.com'
type TelemetryEnv = {
VITE_SENTRY_DSN?: string
VITE_POSTHOG_KEY?: string
VITE_POSTHOG_HOST?: string
}
export type FrontendTelemetryConfig = {
sentryDsn: string
posthogKey: string
posthogHost: string | null
}
function unwrapMatchingQuotes(value: string): string {
if (value.length < 2) return value
const first = value[0]
const last = value[value.length - 1]
if (first !== last) return value
if (first !== '"' && first !== "'") return value
return value.slice(1, -1).trim()
}
export function sanitizeTelemetryEnvValue(value: string | undefined): string {
if (!value) return ''
const trimmed = value.trim()
if (!trimmed) return ''
return unwrapMatchingQuotes(trimmed)
}
function isHttpUrl(value: string): boolean {
try {
const url = new URL(value)
return url.protocol === 'http:' || url.protocol === 'https:'
} catch {
return false
}
}
function normalizeHttpLikeValue(value: string): string {
if (!value) return ''
if (/^[a-z][a-z\d+\-.]*:\/\//i.test(value)) return value
return `https://${value}`
}
function normalizeSentryDsn(value: string): string {
const normalized = normalizeHttpLikeValue(value)
return isHttpUrl(normalized) ? normalized : ''
}
function normalizePostHogHost(value: string): string | null {
if (!value) return DEFAULT_POSTHOG_HOST
const normalized = normalizeHttpLikeValue(value)
return isHttpUrl(normalized) ? normalized : null
}
export function resolveFrontendTelemetryConfig(
env: TelemetryEnv = import.meta.env as TelemetryEnv,
): FrontendTelemetryConfig {
const sentryDsn = normalizeSentryDsn(
sanitizeTelemetryEnvValue(env.VITE_SENTRY_DSN),
)
const posthogKey = sanitizeTelemetryEnvValue(env.VITE_POSTHOG_KEY)
const posthogHost = normalizePostHogHost(
sanitizeTelemetryEnvValue(env.VITE_POSTHOG_HOST),
)
return { sentryDsn, posthogKey, posthogHost }
}
export { DEFAULT_POSTHOG_HOST as _defaultPostHogHostForTest }

View File

@@ -2,6 +2,7 @@ import type { NoteReference } from './ai-context'
export const OPEN_AI_CHAT_EVENT = 'tolaria:open-ai-chat'
export const AI_PROMPT_QUEUED_EVENT = 'tolaria:ai-prompt-queued'
export const NEW_AI_CHAT_EVENT = 'tolaria:new-ai-chat'
export interface QueuedAiPrompt {
id: number
@@ -32,3 +33,8 @@ export function takeQueuedAiPrompt(): QueuedAiPrompt | null {
export function requestOpenAiChat() {
window.dispatchEvent(new Event(OPEN_AI_CHAT_EVENT))
}
export function requestNewAiChat() {
window.dispatchEvent(new Event(NEW_AI_CHAT_EVENT))
requestOpenAiChat()
}

View File

@@ -14,14 +14,14 @@ test.describe('AI chat conversation history', () => {
await noteItem.click()
await page.waitForTimeout(500)
// Open AI Chat with Ctrl+I
await sendShortcut(page, 'i', ['Control'])
// Open AI Chat with the current keyboard shortcut.
await sendShortcut(page, 'L', ['Meta', 'Shift'])
await expect(page.getByTestId('ai-panel')).toBeVisible({ timeout: 3000 })
})
test('first message has no conversation history marker', async ({ page }) => {
test('first message renders a mocked AI response', async ({ page }) => {
// Find the input and send a message
const input = page.locator('input[placeholder*="Ask"]')
const input = page.getByTestId('agent-input')
await input.fill('Hello')
await page.getByTestId('agent-send').click()
@@ -29,36 +29,35 @@ test.describe('AI chat conversation history', () => {
const response = page.getByTestId('ai-message').last()
await expect(response).toBeVisible({ timeout: 5000 })
// First message should have [mock-no-history] since there's no prior conversation
await expect(response).toContainText('[mock-no-history]')
await expect(response).toContainText('[mock-claude code]')
await expect(response).toContainText('You said: "Hello"')
})
test('second message includes conversation history from first exchange', async ({ page }) => {
test('second message appends to the current visible conversation', async ({ page }) => {
// Send first message
const input = page.locator('input[placeholder*="Ask"]')
const input = page.getByTestId('agent-input')
await input.fill('What is 2+2?')
await page.getByTestId('agent-send').click()
// Wait for first response to appear
const firstResponse = page.getByTestId('ai-message').last()
await expect(firstResponse).toBeVisible({ timeout: 5000 })
await expect(firstResponse).toContainText('[mock-no-history]')
await expect(firstResponse).toContainText('[mock-claude code]')
// Send second message
await input.fill('What was my previous question?')
await page.getByTestId('agent-send').click()
// Wait for second response — it should contain history marker
await page.waitForTimeout(1000)
const messages = page.getByTestId('ai-message')
await expect(messages).toHaveCount(2)
await expect(messages.first()).toContainText('What is 2+2?')
const secondResponse = page.getByTestId('ai-message').last()
await expect(secondResponse).toContainText('[mock-with-history', { timeout: 5000 })
// turns=2 means 2 [user] lines: original + new question
await expect(secondResponse).toContainText('turns=2')
await expect(secondResponse).toContainText('What was my previous question?', { timeout: 5000 })
})
test('history resets after clearing conversation', async ({ page }) => {
// Send first message
const input = page.locator('input[placeholder*="Ask"]')
const input = page.getByTestId('agent-input')
await input.fill('Hello')
await page.getByTestId('agent-send').click()
@@ -67,7 +66,7 @@ test.describe('AI chat conversation history', () => {
await expect(firstResponse).toBeVisible({ timeout: 5000 })
// Clear conversation (click the + button)
await page.locator('button[title="New conversation"]').click()
await page.locator('button[title="New AI chat"]').click()
await page.waitForTimeout(300)
// Messages should be cleared
@@ -79,6 +78,31 @@ test.describe('AI chat conversation history', () => {
const freshResponse = page.getByTestId('ai-message').last()
await expect(freshResponse).toBeVisible({ timeout: 5000 })
await expect(freshResponse).toContainText('[mock-no-history]')
await expect(freshResponse).toContainText('[mock-claude code]')
await expect(freshResponse).toContainText('You said: "Fresh start"')
})
test('closing and reopening restores the last chat until a new AI chat is started', async ({ page }) => {
const input = page.getByTestId('agent-input')
await input.fill('Keep this thread alive')
await page.getByTestId('agent-send').click()
const firstResponse = page.getByTestId('ai-message').last()
await expect(firstResponse).toContainText('[mock-claude code]', { timeout: 5000 })
await page.getByTitle('Close AI panel').click()
await expect(page.getByTestId('ai-panel')).toHaveCount(0)
await sendShortcut(page, 'L', ['Meta', 'Shift'])
const panel = page.getByTestId('ai-panel')
await expect(panel).toBeVisible({ timeout: 3_000 })
const restoredMessage = page.getByTestId('ai-message').last()
await expect(restoredMessage).toContainText('Keep this thread alive')
await expect(restoredMessage).toContainText('[mock-claude code]')
await page.keyboard.press('Tab')
await expect(page.getByTitle('New AI chat')).toBeFocused()
await page.keyboard.press('Enter')
await expect(page.getByTestId('ai-message')).toHaveCount(0)
})
})

View File

@@ -181,8 +181,10 @@ test('keyboard onboarding can create an empty vault and the first note', async (
await expect(page.getByTestId('welcome-screen')).toBeVisible()
await expect(page.getByTestId('welcome-create-new')).toContainText('Create empty vault')
await expect(page.getByTestId('welcome-create-new')).toBeFocused()
await expect(page.getByTestId('welcome-create-vault')).toBeFocused()
await page.keyboard.press('Tab')
await expect(page.getByTestId('welcome-create-new')).toBeFocused()
await page.keyboard.press('Enter')
await expect(page.getByTestId('note-list-container')).toBeVisible({ timeout: 5_000 })

View File

@@ -34,7 +34,7 @@ test.describe('Fresh-install regression: AI panel renders and works', () => {
// Layer 1: Header with title and buttons
await expect(panel.locator('text=AI Chat')).toBeVisible()
await expect(panel.locator('button[title="New conversation"]')).toBeVisible()
await expect(panel.locator('button[title="New AI chat"]')).toBeVisible()
await expect(panel.locator('button[title="Close AI panel"]')).toBeVisible()
// Layer 2: Message area (empty state with robot icon suggestion)

View File

@@ -59,7 +59,7 @@ test('accepting telemetry consent on a fresh start opens the vault choice wizard
await expect(page.getByTestId('welcome-screen')).toBeVisible()
await expect(page.getByTestId('welcome-open-folder')).toBeVisible()
await expect(page.getByTestId('welcome-create-new')).toBeFocused()
await expect(page.getByTestId('welcome-create-vault')).toBeFocused()
})
test('telemetry consent still leaves the welcome wizard fully keyboard navigable @smoke', async ({ page }) => {
@@ -76,6 +76,9 @@ test('telemetry consent still leaves the welcome wizard fully keyboard navigable
await page.keyboard.press('Enter')
await expect(page.getByTestId('welcome-screen')).toBeVisible()
await expect(page.getByTestId('welcome-create-vault')).toBeFocused()
await page.keyboard.press('Tab')
await expect(page.getByTestId('welcome-create-new')).toBeFocused()
await page.keyboard.press('Tab')
@@ -92,10 +95,10 @@ test('telemetry consent still leaves the welcome wizard fully keyboard navigable
await expect(page.getByTestId('welcome-screen')).toBeVisible()
await page.keyboard.press('Tab')
await expect(page.getByTestId('welcome-create-vault')).toBeFocused()
await page.keyboard.press('Shift+Tab')
await expect(page.getByTestId('welcome-open-folder')).toBeFocused()
await expect(page.getByTestId('welcome-create-new')).toBeFocused()
await page.keyboard.press('Shift+Tab')
await expect(page.getByTestId('welcome-create-vault')).toBeFocused()
})
for (const action of ['accept', 'decline'] as const) {

View File

@@ -3,6 +3,7 @@ import { test, expect, type Page } from '@playwright/test'
async function installOnboardingMocks(page: Page, offline: boolean) {
await page.addInitScript((isOffline: boolean) => {
localStorage.clear()
let createdVaultPath: string | null = null
let ref: Record<string, unknown> | null = null
@@ -16,11 +17,12 @@ async function installOnboardingMocks(page: Page, offline: boolean) {
hidden_defaults: [],
})
ref.get_default_vault_path = () => '/Users/mock/Documents/Getting Started'
ref.check_vault_exists = () => false
ref.check_vault_exists = (args: { path?: string }) => args.path === createdVaultPath
ref.create_getting_started_vault = (args: { targetPath?: string | null }) => {
if (args.targetPath !== '/Users/mock/Documents/Getting Started') {
throw new Error(`Unexpected Getting Started target: ${args.targetPath}`)
}
createdVaultPath = args.targetPath
return args.targetPath
}
},
@@ -65,6 +67,6 @@ test('status bar keeps a Getting Started clone entry available after onboarding'
await expect(page.getByTestId('claude-onboarding-screen')).toBeVisible()
await page.getByTestId('claude-onboarding-continue').click()
await expect(page.locator('[data-testid="note-list-container"]')).toBeVisible()
await page.getByTitle('Switch vault').click()
await page.getByTestId('status-vault-trigger').click()
await expect(page.getByTestId('vault-menu-clone-getting-started')).toBeVisible()
})

View File

@@ -0,0 +1,135 @@
import { test, expect, type Page } from '@playwright/test'
interface StaleStarterPaths {
gettingStartedPath: string
personalVaultPath: string
workVaultPath: string
}
function buildPaths(): StaleStarterPaths {
return {
gettingStartedPath: '/Users/mock/Documents/Getting Started',
personalVaultPath: '/Users/mock/Personal',
workVaultPath: '/Users/mock/Work',
}
}
async function installStaleStarterMocks(page: Page) {
const paths = buildPaths()
await page.addInitScript((data: StaleStarterPaths) => {
localStorage.clear()
localStorage.setItem('tolaria:claude-code-onboarding-dismissed', '1')
let ref: Record<string, unknown> | null = null
Object.defineProperty(window, '__mockHandlers', {
configurable: true,
set(value) {
ref = value as Record<string, unknown>
ref.load_vault_list = () => ({
vaults: [
{ label: 'Getting Started', path: data.gettingStartedPath },
{ label: 'Work Vault', path: data.workVaultPath },
{ label: 'Personal Vault', path: data.personalVaultPath },
],
active_vault: data.workVaultPath,
hidden_defaults: [],
})
ref.get_default_vault_path = () => data.gettingStartedPath
ref.check_vault_exists = (args: { path?: string }) =>
args?.path === data.workVaultPath || args?.path === data.personalVaultPath
ref.list_vault = (args: { path?: string }) => {
if (args?.path === data.workVaultPath) {
return [{
path: `${data.workVaultPath}/work-home.md`,
filename: 'work-home.md',
title: 'Work Home',
isA: 'Note',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
archived: false,
modifiedAt: 1700000000,
createdAt: null,
fileSize: 256,
snippet: 'Work Home snippet',
wordCount: 12,
relationships: {},
outgoingLinks: [],
properties: {},
template: null,
sort: null,
}]
}
if (args?.path === data.personalVaultPath) {
return [{
path: `${data.personalVaultPath}/personal-home.md`,
filename: 'personal-home.md',
title: 'Personal Home',
isA: 'Note',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
archived: false,
modifiedAt: 1700000000,
createdAt: null,
fileSize: 256,
snippet: 'Personal Home snippet',
wordCount: 12,
relationships: {},
outgoingLinks: [],
properties: {},
template: null,
sort: null,
}]
}
return []
}
ref.list_vault_folders = () => []
ref.list_views = () => []
ref.get_all_content = () => ({
[`${data.workVaultPath}/work-home.md`]: '# Work Home\n\nWork Home snippet',
[`${data.personalVaultPath}/personal-home.md`]: '# Personal Home\n\nPersonal Home snippet',
})
ref.get_note_content = (args: { path?: string }) => {
if (args?.path === `${data.workVaultPath}/work-home.md`) {
return '# Work Home\n\nWork Home snippet'
}
if (args?.path === `${data.personalVaultPath}/personal-home.md`) {
return '# Personal Home\n\nPersonal Home snippet'
}
return ''
}
ref.get_modified_files = () => []
ref.get_file_history = () => []
},
get() {
return ref
},
})
}, paths)
}
test('stale persisted Getting Started entries stay hidden when the starter path is missing @smoke', async ({ page }) => {
await installStaleStarterMocks(page)
await page.goto('/', { waitUntil: 'domcontentloaded' })
const trigger = page.getByTestId('status-vault-trigger')
await expect(trigger).toContainText('Work Vault')
await trigger.focus()
await expect(trigger).toBeFocused()
await page.keyboard.press('Enter')
await expect(page.getByTestId('vault-menu-item-Getting Started')).toHaveCount(0)
await expect(page.getByTestId('vault-menu-item-Work Vault')).toBeVisible()
await expect(page.getByTestId('vault-menu-item-Personal Vault')).toBeVisible()
})