Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions apps/ios/CovenCave/CovenCave/State/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,13 @@ final class AppModel {
var familiarOrder: [String] = []

var threads: [ChatThread] = []
/// Default Chats destination: the newest active conversation. Pinning only
/// affects list order and never makes an older thread the launch default.
var mostRecentThread: ChatThread? {
threads
.filter { !$0.archived }
.max { $0.updatedAt < $1.updatedAt }
}
/// Process-lifetime launch intent. It survives destination remounts until a
/// matching hydrated thread can be opened, then is consumed exactly once.
var launchThreadId: String?
Expand Down
15 changes: 15 additions & 0 deletions apps/ios/CovenCave/CovenCave/Views/ChatsHomeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,11 @@ struct ChatsHomeView: View {
.onAppear {
consumeLaunchThreadIntent()
consumeGlobalRequests()
selectMostRecentThreadIfNeeded()
}
.onChange(of: app.threads.map(\.id)) { _, _ in
consumeLaunchThreadIntent()
selectMostRecentThreadIfNeeded()
}
// A slash command (`/new`, `/familiar <name>`) or a task link asked to
// open a specific thread — surface it in the detail column.
Expand Down Expand Up @@ -390,6 +392,19 @@ struct ChatsHomeView: View {
open(.thread(thread))
}

/// Open Chats at the latest active conversation without stealing focus from
/// a deep link, cross-view handoff, New Chat, or an existing selection.
private func selectMostRecentThreadIfNeeded() {
guard selection == nil,
!showNewChat,
app.threadToOpen == nil,
app.launchThreadId == nil,
!app.newChatRequested,
let thread = app.mostRecentThread
else { return }
open(.thread(thread))
}

/// Consume a cross-destination thread handoff on first appearance and on
/// later updates. Clearing the one-shot intent prevents re-appearance from
/// reopening the same conversation.
Expand Down
2 changes: 1 addition & 1 deletion apps/ios/CovenCave/CovenCave/Views/RootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,7 @@ struct ConnectingView: View {
.accessibilityHidden(true)
.padding(.bottom, 34)

Text("Opening the Cave")
Text("Entering the Cave")
.font(.title.weight(.medium))
.fontDesign(.serif)
.italic()
Expand Down
27 changes: 27 additions & 0 deletions apps/ios/CovenCave/CovenCaveTests/LaunchThreadIntentTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,31 @@ final class LaunchThreadIntentTests: XCTestCase {
app.threads = [expected]
XCTAssertTrue(app.consumeLaunchThreadIntent() === expected)
}

func testMostRecentThreadUsesUpdateTimeAndSkipsArchivedThreads() {
let app = AppModel()
let olderPinned = ChatThread(id: "older-pinned", title: "Older pinned", familiarIds: [])
olderPinned.updatedAt = Date(timeIntervalSince1970: 100)
olderPinned.pinned = true

let newest = ChatThread(id: "newest", title: "Newest", familiarIds: [])
newest.updatedAt = Date(timeIntervalSince1970: 200)

let archived = ChatThread(id: "archived", title: "Archived", familiarIds: [])
archived.updatedAt = Date(timeIntervalSince1970: 300)
archived.archived = true

app.threads = [olderPinned, archived, newest]

XCTAssertTrue(app.mostRecentThread === newest)
}

func testMostRecentThreadIsNilWithoutAnActiveConversation() {
let app = AppModel()
let archived = ChatThread(id: "archived", title: "Archived", familiarIds: [])
archived.archived = true
app.threads = [archived]

XCTAssertNil(app.mostRecentThread)
}
}
21 changes: 18 additions & 3 deletions scripts/ios-claude-design-fidelity.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,11 @@ assert.match(
/func consumeLaunchThreadIntent\(\) -> ChatThread\?/,
"the launch thread intent waits for a matching thread before consuming",
);
assert.match(
appModel,
/var mostRecentThread: ChatThread\? \{[\s\S]{0,180}filter \{ !\$0\.archived \}[\s\S]{0,180}max \{ \$0\.updatedAt < \$1\.updatedAt \}/,
"the default chat is the newest active thread, independent of pin order",
);
assert.match(
appModel,
/if let threadId = ChatNotifications\.threadId\(fromDeepLink: url\) \{[\s\S]{0,140}launchThreadId = threadId[\s\S]{0,140}selectedTab = \.chats/,
Expand All @@ -109,8 +114,18 @@ assert.doesNotMatch(
);
assert.match(
home,
/onChange\(of: app\.threads\.map\(\\\.id\)\)[\s\S]{0,120}consumeLaunchThreadIntent\(\)/,
"Chats retries a pending launch intent when hydration adds threads",
/\.onAppear \{\s*consumeLaunchThreadIntent\(\)\s*consumeGlobalRequests\(\)\s*selectMostRecentThreadIfNeeded\(\)\s*\}/,
"Chats selects the most recent thread after honoring explicit launch requests",
);
assert.match(
home,
/onChange\(of: app\.threads\.map\(\\\.id\)\)[\s\S]{0,160}consumeLaunchThreadIntent\(\)[\s\S]{0,160}selectMostRecentThreadIfNeeded\(\)/,
"Chats retries explicit and default selection when hydration adds threads",
);
assert.match(
home,
/private func selectMostRecentThreadIfNeeded\(\) \{[\s\S]{0,500}guard selection == nil,[\s\S]{0,500}!showNewChat,[\s\S]{0,500}app\.threadToOpen == nil,[\s\S]{0,500}app\.launchThreadId == nil,[\s\S]{0,500}!app\.newChatRequested,[\s\S]{0,500}let thread = app\.mostRecentThread[\s\S]{0,180}open\(\.thread\(thread\)\)/,
"the default never overrides an explicit destination or New Chat intent",
);

// Authored navigation and discovery surfaces.
Expand Down Expand Up @@ -144,7 +159,7 @@ assert.match(glass, /UINavigationBarAppearance\.glass/,

// Quiet Portal keeps the cold connection state honest, themed, deterministic,
// and accessible without inventing stages the runtime cannot prove.
assert.match(root, /Text\("Opening the Cave"\)/, "connecting state uses the approved headline");
assert.match(root, /Text\("Entering the Cave"\)/, "connecting state uses the approved headline");
assert.match(root, /Text\("Connecting to your desktop"\)/, "connecting state names the live operation");
assert.match(root, /if let host = app\.connection\?\.host/, "connecting state renders the real saved host");
assert.match(
Expand Down
4 changes: 3 additions & 1 deletion src/components/daemon-start-button.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";

const settings = await readFile(new URL("./settings-shell.tsx", import.meta.url), "utf8");
const settingsShell = await readFile(new URL("./settings-shell.tsx", import.meta.url), "utf8");
const settingsDaemon = await readFile(new URL("./settings-daemon.tsx", import.meta.url), "utf8");
const settings = `${settingsShell}\n${settingsDaemon}`;
const workspace = await readFile(new URL("./workspace.tsx", import.meta.url), "utf8");

assert.match(settings, /fetch\("\/api\/daemon\/start", \{ method: "POST" \}\)/);
Expand Down
6 changes: 4 additions & 2 deletions src/components/settings-action-buttons.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { readFileSync } from "node:fs";
import ts from "typescript";

const shell = readFileSync(new URL("./settings-shell.tsx", import.meta.url), "utf8");
const daemon = readFileSync(new URL("./settings-daemon.tsx", import.meta.url), "utf8");
const picker = readFileSync(new URL("./settings-familiar-picker.tsx", import.meta.url), "utf8");
const controls = readFileSync(new URL("./ui/settings-controls.tsx", import.meta.url), "utf8");
const css = readFileSync(new URL("../app/globals.css", import.meta.url), "utf8");
Expand Down Expand Up @@ -45,12 +46,13 @@ function jsxElementBlocks(fileName: string, source: string, tagName: string): st
}

const reviewedSemanticControl = (button: string): boolean =>
/goToSetting\(e\)|selectDevice\(device\)|settings-nav__item|role="switch"|aria-pressed=|aria-label=\{`Pick \$\{label\} color`\}|aria-haspopup="dialog"|role="option"|role="radio"|role="checkbox"|setEnlarged\(true\)|Drag to reorder|familiar-studio-lifecycle__row-main/.test(
/goToSetting\(e\)|selectDevice\(device\)|settings-nav__item|role="switch"|aria-pressed=|aria-expanded=|aria-label=\{`Pick \$\{label\} color`\}|aria-haspopup="dialog"|role="option"|role="radio"|role="checkbox"|setEnlarged\(true\)|Drag to reorder|familiar-studio-lifecycle__row-main/.test(
button,
);

for (const [name, source] of [
["settings shell", shell],
["settings daemon", daemon],
["settings familiar picker", picker],
["settings segmented control", controls],
...studioSources,
Expand All @@ -72,7 +74,7 @@ for (const [name, source] of [
}
}

const saveConnectionButtons = jsxElementBlocks("settings-shell.tsx", shell, "Button").filter((block) =>
const saveConnectionButtons = jsxElementBlocks("settings-daemon.tsx", daemon, "Button").filter((block) =>
block.includes("Save connection"),
);
assert.equal(saveConnectionButtons.length, 1, "Save connection renders through exactly one shared Button");
Expand Down
76 changes: 74 additions & 2 deletions src/components/settings-daemon-multihost.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,81 @@
// @ts-nocheck
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";

const shell = readFileSync(new URL("./settings-shell.tsx", import.meta.url), "utf8");
const shellEntry = readFileSync(new URL("./settings-shell.tsx", import.meta.url), "utf8");
const daemonUrl = new URL("./settings-daemon.tsx", import.meta.url);
const daemon = existsSync(daemonUrl) ? readFileSync(daemonUrl, "utf8") : "";
const shell = `${shellEntry}\n${daemon}`;
const sections = readFileSync(new URL("./settings-sections.ts", import.meta.url), "utf8");
const daemonCssUrl = new URL("../styles/settings-daemon.css", import.meta.url);
const daemonCss = existsSync(daemonCssUrl) ? readFileSync(daemonCssUrl, "utf8") : "";

// ── Claude Design daemon control sheet ───────────────────────────────────────
assert.match(
shellEntry,
/import \{ DaemonSection \} from "\.\/settings-daemon"/,
"SettingsShell should delegate the daemon control sheet to a focused component",
);
assert.match(
daemon,
/export function DaemonSection/,
"the focused daemon module should export the Settings section",
);
assert.match(daemon, /className="settings-daemon"/, "the daemon page should own a responsive control-sheet container");
assert.match(daemon, /className="settings-daemon-hero"/, "the daemon page should open with the approved compact hero");
assert.match(daemon, /Settings · Daemon/, "the hero should carry the approved settings kicker");
assert.match(daemon, /className="settings-daemon-chip-list"/, "the hero should summarize target, API, queue, and uptime");
assert.match(daemon, />\s*Refresh\s*</, "the hero should expose an explicit status refresh");
assert.match(daemon, /Restart daemon/, "the hero should expose the daemon restart action");
assert.match(daemon, /Start daemon/, "the hero should make an offline local daemon actionable");

assert.match(daemon, /className="settings-daemon-status-card"/, "daemon state should render as the approved status card");
assert.match(daemon, /className="settings-daemon-status-strip"/, "the primary daemon state should lead the status card");
assert.match(daemon, /className="settings-daemon-status-grid"/, "the six daemon metrics should share a dense grid");
for (const label of [
"AUTHORITY",
"PENDING QUEUE",
"LOCAL BIND",
"STALE CACHE",
"WAKE LOCAL",
"HANDOFF",
]) {
assert.match(daemon, new RegExp(`label: "${label}"`), `the status grid should include ${label}`);
}
assert.match(daemon, />HOME</, "travel state should expose the approved HOME segment");
assert.match(daemon, />AWAY</, "travel state should expose the approved AWAY segment");
assert.match(daemon, /Manual offline/, "travel state should preserve the real manual-offline action");
assert.match(daemon, /Back online/, "manual-offline state should expose the matching recovery action");

assert.match(daemon, /className="settings-daemon-target-grid"/, "runtime targets should render as selectable cards");
assert.match(daemon, /aria-pressed=\{mode === target\.id\}/, "runtime target cards should expose selection programmatically");
assert.match(daemon, /<TextInput[\s\S]*aria-label="Server hub URL"/, "the hub field should reuse the shared text-input primitive");
assert.match(daemon, /<TextArea[\s\S]*aria-label="Executor addresses, one per line"/, "executor addresses should reuse the shared textarea primitive");
assert.match(daemon, /aria-expanded=\{executorsOpen\}/, "executor addresses should use progressive disclosure");
assert.match(daemon, /optional · multi-machine setups/, "executor disclosure should explain its advanced scope");
assert.match(daemon, /const connectionDirty =/, "connection changes should remain drafts until explicitly saved");
assert.match(daemon, />\s*Revert\s*</, "connection drafts should be reversible");
assert.match(daemon, />\s*Save connection\s*</, "connection drafts should have one explicit save action");

assert.match(daemon, /className="settings-daemon-info"/, "daemon metadata should use the compact approved info table");
assert.match(daemon, /copyInfoValue/, "copyable daemon paths should share one announced copy path");
assert.match(shell, /omnigentSettings=\{<OmnigentSettingsGroup \/>\}/, "the Vault-gated Omnigent settings must remain reachable from Daemon");

assert.match(
daemonCss,
/@container settings-daemon \(max-width:/,
"the control sheet should adapt to its pane with a container query",
);
assert.match(
daemonCss,
/@media \(prefers-reduced-motion: reduce\)/,
"daemon-specific motion should have an explicit reduced-motion treatment",
);
assert.doesNotMatch(
shell,
/bg-red-400/,
"daemon error states should use the semantic danger token across every theme",
);

assert.match(
shell,
Expand Down
Loading
Loading