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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -343,3 +343,66 @@ After each phase, briefly report:
- ➑️ Next phase plan

This keeps the human informed and provides natural points to course-correct.

## Subagents (Context-Isolated Tasks)

VS Code's `#runSubagent` tool enables context-isolated task execution. Subagents run independently with their own context, preventing context confusion in complex tasks.

### When to Use Subagents

| Task Type | Use Subagent? | Rationale |
|-----------|---------------|-----------|
| Research API endpoints | βœ… Yes | Keeps raw JSON exploration out of main context |
| Analyze unfamiliar code areas | βœ… Yes | Deep dives don't pollute main conversation |
| Review a single file for patterns | βœ… Yes | Focused analysis, returns summary only |
| Generate test fixtures | βœ… Yes | Boilerplate generation isolated from design discussion |
| Simple edits to known files | ❌ No | Direct action is faster |
| Multi-step refactoring | ❌ No | Needs continuous context across steps |
| Tasks requiring user feedback | ❌ No | Subagents don't pause for input |

### Subagent Prompts for This Project

**API Research** β€” Explore an endpoint before implementing:
```
Analyze the YouTube Music API endpoint structure for #file:docs/api-discovery.md with #runSubagent.
Focus on FEmusic_liked_playlists response format and identify all playlist item fields.
Return a summary of the response structure suitable for writing a parser.
```

**Code Pattern Analysis** β€” Understand existing patterns:
```
With #runSubagent, analyze #file:Core/Services/API/YTMusicClient.swift and identify:
1. How authenticated requests are constructed
2. Error handling patterns
3. How parsers are invoked
Return a concise pattern guide for adding a new endpoint method.
```

**Parser Stub Generation** β€” Generate boilerplate:
```
Using #runSubagent, generate a Swift parser struct following the pattern in #file:Core/Services/API/Parsers/
for parsing a "moods and genres" API response with categories containing playlists.
Return only the struct definition with placeholder parsing logic.
```

**Performance Audit** β€” Isolated deep dive:
```
With #runSubagent, audit #file:Views/macOS/LibraryView.swift for SwiftUI performance issues.
Check for: await in ForEach, missing LazyVStack, inline image loading, excessive state updates.
Return a prioritized list of issues with line numbers.
```

### Subagent Best Practices

1. **Be specific in prompts** β€” Subagents don't have conversation history; include all necessary context
2. **Request structured output** β€” Ask for summaries, lists, or code snippets that integrate cleanly
3. **Use for exploration, not execution** β€” Subagents are great for research; keep edits in main context
4. **Combine with file references** β€” Use `#file:path` to give subagents focused context
5. **Review before integrating** β€” Subagent results join main context; verify accuracy first

### Anti-Patterns

- ❌ Using subagents for quick lookups (overhead not worth it)
- ❌ Chaining multiple subagents (use main context for multi-step work)
- ❌ Expecting subagents to remember previous subagent results
- ❌ Using subagents for tasks requiring user clarification
20 changes: 17 additions & 3 deletions App/KasetApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,12 @@ struct KasetApp: App {
@State private var authService = AuthService()
@State private var webKitManager = WebKitManager.shared
@State private var playerService = PlayerService()
@State private var ytMusicClient: YTMusicClient?
@State private var sharedClient: any YTMusicClientProtocol
@State private var notificationService: NotificationService?
@State private var updaterService = UpdaterService()
@State private var favoritesManager = FavoritesManager.shared
@State private var likeStatusManager = SongLikeStatusManager.shared
@State private var accountService: AccountService?

/// Triggers search field focus when set to true.
@State private var searchFocusTrigger = false
Expand Down Expand Up @@ -87,11 +88,20 @@ struct KasetApp: App {
player.setYTMusicClient(client)
SongLikeStatusManager.shared.setClient(client)

// Create account service
let account = AccountService(ytMusicClient: client, authService: auth)

// Wire up brand account provider so API requests use the correct account
realClient.brandIdProvider = { [weak account] in
account?.currentBrandId
}

_authService = State(initialValue: auth)
_webKitManager = State(initialValue: webkit)
_playerService = State(initialValue: player)
_ytMusicClient = State(initialValue: UITestConfig.isUITestMode ? nil : realClient)
_sharedClient = State(initialValue: client)
_notificationService = State(initialValue: NotificationService(playerService: player))
_accountService = State(initialValue: account)

if UITestConfig.isUITestMode {
DiagnosticsLogger.ui.info("App launched in UI Test mode")
Expand All @@ -105,12 +115,13 @@ struct KasetApp: App {
Color.clear
.frame(width: 1, height: 1)
} else {
MainWindow(navigationSelection: self.$navigationSelection)
MainWindow(navigationSelection: self.$navigationSelection, client: self.sharedClient)
.environment(self.authService)
.environment(self.webKitManager)
.environment(self.playerService)
.environment(self.favoritesManager)
.environment(self.likeStatusManager)
.environment(self.accountService)
.environment(\.searchFocusTrigger, self.$searchFocusTrigger)
.environment(\.navigationSelection, self.$navigationSelection)
.environment(\.showCommandBar, self.$showCommandBar)
Expand All @@ -121,6 +132,9 @@ struct KasetApp: App {
// Check if user is already logged in from previous session
await self.authService.checkLoginStatus()

// Fetch accounts after login check (for account switcher)
await self.accountService?.fetchAccounts()

// Warm up Foundation Models in background
await FoundationModelsService.shared.warmup()
}
Expand Down
63 changes: 63 additions & 0 deletions Core/Models/AccountsListResponse.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// AccountsListResponse.swift
// Kaset
//
// Created for account switcher feature.

import Foundation

/// Response containing the list of available user accounts.
///
/// This struct represents the parsed response from the YouTube Music accounts list API,
/// containing all accounts (primary and brand) associated with the authenticated user.
///
/// ## Usage
/// ```swift
/// let response = AccountsListResponse(
/// googleEmail: "user@gmail.com",
/// accounts: [primaryAccount, brandAccount1, brandAccount2]
/// )
///
/// if let selected = response.selectedAccount {
/// DiagnosticsLogger.log("Current account: \(selected.name)")
/// }
///
/// if response.hasMultipleAccounts {
/// // Show account switcher UI
/// }
/// ```
public struct AccountsListResponse: Sendable {
// MARK: - Properties

/// The Google email address associated with the primary account.
/// Extracted from the response header.
public let googleEmail: String?

/// All available accounts (primary and brand accounts).
public let accounts: [UserAccount]

// MARK: - Computed Properties

/// The currently selected/active account.
/// Returns the first account where `isSelected` is true, or nil if none selected.
public var selectedAccount: UserAccount? {
self.accounts.first { $0.isSelected }
}

/// Whether multiple accounts are available for switching.
/// Returns `true` if more than one account exists.
public var hasMultipleAccounts: Bool {
self.accounts.count > 1
}

// MARK: - Initialization

/// Creates a new AccountsListResponse.
///
/// - Parameters:
/// - googleEmail: The Google email from the response header.
/// - accounts: Array of parsed UserAccount objects.
public init(googleEmail: String?, accounts: [UserAccount]) {
self.googleEmail = googleEmail
self.accounts = accounts
}
}
113 changes: 113 additions & 0 deletions Core/Models/UserAccount.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// UserAccount.swift
// Kaset
//
// Created for account switcher feature.

import Foundation

/// Represents a YouTube Music user account (primary or brand account).
///
/// YouTube Music allows users to have multiple accounts:
/// - **Primary account**: The main Google account (no brandId)
/// - **Brand accounts**: Managed channel accounts associated with the primary account
///
/// ## API Response Mapping
/// - `name`: From `accountName.runs[0].text`
/// - `handle`: From `channelHandle.runs[0].text` (optional)
/// - `brandId`: From `serviceEndpoint.selectActiveIdentityEndpoint.supportedTokens[].pageIdToken.pageId`
/// - `thumbnailURL`: From `accountPhoto.thumbnails[0].url`
/// - `isSelected`: From `isSelected`
public struct UserAccount: Identifiable, Equatable, Sendable, Hashable {
// MARK: - Properties

/// Unique identifier for the account.
/// Uses `brandId` for brand accounts, "primary" for the main account.
public let id: String

/// Display name of the account.
public let name: String

/// Channel handle (e.g., "@username"), if available.
public let handle: String?

/// Brand account identifier, nil for primary accounts.
public let brandId: String?

/// URL for the account's profile photo thumbnail.
public let thumbnailURL: URL?

/// Whether this account is currently selected/active.
public let isSelected: Bool

// MARK: - Computed Properties

/// Returns `true` if this is the primary Google account (not a brand account).
public var isPrimary: Bool {
self.brandId == nil
}

/// Human-readable label for the account type.
/// Returns "Personal" for primary accounts, "Brand" for brand accounts.
public var typeLabel: String {
self.isPrimary ? "Personal" : "Brand"
}

// MARK: - Initialization

/// Creates a new UserAccount instance.
///
/// - Parameters:
/// - id: Unique identifier for the account.
/// - name: Display name of the account.
/// - handle: Optional channel handle.
/// - brandId: Brand account identifier (nil for primary).
/// - thumbnailURL: URL for the profile photo.
/// - isSelected: Whether the account is currently active.
public init(
id: String,
name: String,
handle: String?,
brandId: String?,
thumbnailURL: URL?,
isSelected: Bool
) {
self.id = id
self.name = name
self.handle = handle
self.brandId = brandId
self.thumbnailURL = thumbnailURL
self.isSelected = isSelected
}

// MARK: - Factory Methods

/// Creates a UserAccount from API response fields.
///
/// This factory method automatically determines the account ID based on whether
/// a brandId is provided.
///
/// - Parameters:
/// - name: Display name from `accountName.runs[0].text`.
/// - handle: Optional handle from `channelHandle.runs[0].text`.
/// - brandId: Brand ID from `pageIdToken.pageId`, nil for primary account.
/// - thumbnailURL: Thumbnail URL from `accountPhoto.thumbnails[0].url`.
/// - isSelected: Selection state from `isSelected`.
/// - Returns: A configured UserAccount instance.
public static func from(
name: String,
handle: String?,
brandId: String?,
thumbnailURL: URL?,
isSelected: Bool
) -> UserAccount {
let accountId = brandId ?? "primary"
return UserAccount(
id: accountId,
name: name,
handle: handle,
brandId: brandId,
thumbnailURL: thumbnailURL,
isSelected: isSelected
)
}
}
16 changes: 13 additions & 3 deletions Core/Services/API/APICache.swift
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,10 @@ final class APICache {

private static let logger = DiagnosticsLogger.api

/// Generates a stable, deterministic cache key from endpoint and request body.
/// Generates a stable, deterministic cache key from endpoint, request body, and brand ID.
/// Uses SHA256 hash of sorted JSON to ensure consistency.
static func stableCacheKey(endpoint: String, body: [String: Any]) -> String {
/// Including brandId ensures cache isolation between accounts.
static func stableCacheKey(endpoint: String, body: [String: Any], brandId: String = "") -> String {
// Use JSONSerialization with .sortedKeys for deterministic output
// This is more efficient than custom recursive string building
let jsonData: Data
Expand All @@ -104,7 +105,16 @@ final class APICache {
// Return endpoint-only key with error marker to avoid collisions
return "\(endpoint):serialization_error_\(body.count)"
}
let hash = SHA256.hash(data: jsonData)

// Include brand ID in hash to isolate cache between accounts
var hashData = jsonData
if !brandId.isEmpty {
Comment on lines +110 to +111
Copy link

Copilot AI Jan 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Appending brandId as raw UTF-8 bytes to the hash data could cause collisions if endpoint/body combinations produce JSON that ends with bytes matching a brandId prefix. Consider using a separator or structured format (e.g., JSON with explicit brandId field) to ensure unambiguous cache key generation.

Suggested change
var hashData = jsonData
if !brandId.isEmpty {
var hashData = Data()
hashData.append(jsonData)
if !brandId.isEmpty {
// Use a NUL byte as a separator to avoid ambiguity between JSON and brandId bytes
hashData.append(0)

Copilot uses AI. Check for mistakes.
// Use NUL byte separator to avoid ambiguity between JSON and brandId bytes
hashData.append(0)
hashData.append(Data(brandId.utf8))
}

let hash = SHA256.hash(data: hashData)
let hashString = hash.prefix(16).compactMap { String(format: "%02x", $0) }.joined()
return "\(endpoint):\(hashString)"
}
Expand Down
Loading
Loading