Compare commits
8 Commits
stable-v20
...
alpha-v202
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9536efee0e | ||
|
|
c86a6ab9c5 | ||
|
|
1d7c027700 | ||
|
|
74e50c64bb | ||
|
|
8b97ef711f | ||
|
|
08855cee92 | ||
|
|
03042bb49c | ||
|
|
cee4bef179 |
57
.github/workflows/release-stable.yml
vendored
57
.github/workflows/release-stable.yml
vendored
@@ -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 }}
|
||||
|
||||
57
.github/workflows/release.yml
vendored
57
.github/workflows/release.yml
vendored
@@ -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 }}
|
||||
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
10
src/assets/tolaria-icon.svg
Normal file
10
src/assets/tolaria-icon.svg
Normal 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 |
@@ -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()
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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,7 +31,14 @@ 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('renders the local Tolaria branding icon', () => {
|
||||
render(<WelcomeScreen {...defaultProps} />)
|
||||
|
||||
const brandIcon = screen.getByAltText('Tolaria icon')
|
||||
expect(brandIcon).toHaveAttribute('src', tolariaIcon)
|
||||
})
|
||||
|
||||
it('shows all three option buttons', () => {
|
||||
|
||||
@@ -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)' }}>✦</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',
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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))
|
||||
|
||||
105
src/hooks/useGitHistory.test.ts
Normal file
105
src/hooks/useGitHistory.test.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
: []
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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',
|
||||
|
||||
71
src/lib/telemetryConfig.test.ts
Normal file
71
src/lib/telemetryConfig.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
75
src/lib/telemetryConfig.ts
Normal file
75
src/lib/telemetryConfig.ts
Normal 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 }
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
135
tests/smoke/stale-getting-started-switcher.spec.ts
Normal file
135
tests/smoke/stale-getting-started-switcher.spec.ts
Normal 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()
|
||||
})
|
||||
Reference in New Issue
Block a user