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
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,44 @@ struct AgentThreadComposerSuggestions: View {
@State private var loadedTrigger: AgentThreadComposerTrigger?
@State private var isSearching = false
@State private var errorMessage: String?
@State private var scopedCatalog: [String: JSONValue] = [:]
@State private var loadedCatalogTarget: [String]?
@State private var catalogError: String?

private var catalogCwd: String? { model.thread.shell.worktreePath ?? workspaceRoot }
private var catalogTarget: [String] {
[model.currentModelSelection.instanceId, catalogCwd ?? "", provider["provider"]?.stringValue ?? "",
trigger.kind == .skill || trigger.kind == .slash ? "open" : "closed"]
}
private var needsScopedCatalog: Bool {
provider["provider"]?.stringValue == "claudeAgent" && (trigger.kind == .skill || trigger.kind == .slash)
}
private var composerCatalog: [String: JSONValue] {
guard needsScopedCatalog else { return provider }
return loadedCatalogTarget == catalogTarget ? scopedCatalog : [:]
}
private var catalogIsLoading: Bool { needsScopedCatalog && loadedCatalogTarget != catalogTarget }

private func loadComposerCatalog() async {
guard needsScopedCatalog else { catalogError = nil; loadedCatalogTarget = nil; return }
let target = catalogTarget
catalogError = nil
do {
let result = try await model.request("server.getComposerCatalog", payload: .object([
"instanceId": .string(model.currentModelSelection.instanceId), "cwd": catalogCwd.map(JSONValue.string) ?? .null
]), reportsErrors: false)
try Task.checkCancellation()
guard target == catalogTarget else { return }
scopedCatalog = result.objectValue ?? [:]
loadedCatalogTarget = target
} catch is CancellationError { return }
catch {
guard !Task.isCancelled, target == catalogTarget else { return }
scopedCatalog = [:]
loadedCatalogTarget = target
catalogError = "Could not load this project's commands. Close and reopen the menu to retry."
}
}

private var provider: [String: JSONValue] {
model.serverConfig["providers"]?.arrayValue?.compactMap(\.objectValue)
Expand All @@ -86,8 +124,8 @@ struct AgentThreadComposerSuggestions: View {
}
}
case .skill:
candidates = (provider["skills"]?.arrayValue ?? []).compactMap { value in
guard let fields = value.objectValue, fields["enabled"]?.boolValue == true,
candidates = (composerCatalog["skills"]?.arrayValue ?? []).compactMap { value in
guard let fields = value.objectValue, fields["enabled"]?.boolValue == true, fields["userInvocable"]?.boolValue != false,
let name = fields["name"]?.stringValue else { return nil }
return .init(id: "skill:\(name)", title: fields["displayName"]?.stringValue ?? name,
detail: fields["shortDescription"]?.stringValue ?? fields["description"]?.stringValue ?? "$\(name)",
Expand All @@ -104,7 +142,7 @@ struct AgentThreadComposerSuggestions: View {
]
}
}
commands += (provider["slashCommands"]?.arrayValue ?? []).compactMap { value in
commands += (trigger.range.location == 0 ? composerCatalog["slashCommands"]?.arrayValue ?? [] : []).compactMap { value in
guard let fields = value.objectValue, let name = fields["name"]?.stringValue else { return nil }
return .init(id: "command:\(name)", title: "/\(name)",
detail: fields["description"]?.stringValue ?? fields["input"]?.objectValue?["hint"]?.stringValue ?? "Provider command",
Expand All @@ -122,8 +160,10 @@ struct AgentThreadComposerSuggestions: View {
ScrollView {
LazyVStack(alignment: .leading, spacing: 0) {
if isSearching { ProgressView("Searching files…").font(.caption).padding(10) }
if catalogIsLoading { ProgressView("Loading commands…").font(.caption).padding(10) }
if needsScopedCatalog, loadedCatalogTarget == catalogTarget, let catalogError { Text(catalogError).font(.caption).padding(10) }
if let errorMessage { Text(errorMessage).font(.caption).foregroundStyle(.secondary).padding(10) }
if items.isEmpty && !isSearching && errorMessage == nil {
if items.isEmpty && !isSearching && !catalogIsLoading && catalogError == nil && errorMessage == nil {
Text(model.isConfigurationLocked && trigger.kind == .model ? model.configurationLockReason ?? "Model is managed by the parent thread" : "No matching suggestions")
.font(.caption).foregroundStyle(.secondary).padding(10)
}
Expand All @@ -148,6 +188,7 @@ struct AgentThreadComposerSuggestions: View {
.accessibilityElement(children: .contain)
.accessibilityIdentifier("agent-thread-composer-suggestions")
.task(id: trigger) { await searchPaths() }
.task(id: catalogTarget) { await loadComposerCatalog() }
.task(id: model.currentModelSelection.instanceId) {
if trigger.kind != .path && provider.isEmpty { await model.refreshServerConfig() }
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,53 @@ struct NewAgentThreadSuggestions: View {
@State private var loadedTrigger: AgentThreadComposerTrigger?
@State private var isLoading = false
@State private var errorMessage: String?
@State private var scopedCatalog: [String: JSONValue] = [:]
@State private var loadedCatalogTarget: [String]?
@State private var catalogError: String?

private var catalogCwd: String? { model.workspaceRoot }
private var catalogTarget: [String] {
[model.selectedProviderID, catalogCwd ?? "", provider["provider"]?.stringValue ?? "",
trigger.kind == .skill || trigger.kind == .slash ? "open" : "closed"]
}
private var needsScopedCatalog: Bool {
provider["provider"]?.stringValue == "claudeAgent" && (trigger.kind == .skill || trigger.kind == .slash)
}
private var composerCatalog: [String: JSONValue] {
guard needsScopedCatalog else { return provider }
return loadedCatalogTarget == catalogTarget ? scopedCatalog : [:]
}
private var catalogIsLoading: Bool { needsScopedCatalog && loadedCatalogTarget != catalogTarget }

private func loadComposerCatalog() async {
guard needsScopedCatalog else { catalogError = nil; loadedCatalogTarget = nil; return }
let target = catalogTarget
catalogError = nil
do {
let result = try await model.request("server.getComposerCatalog", payload: .object([
"instanceId": .string(model.selectedProviderID), "cwd": catalogCwd.map(JSONValue.string) ?? .null
]))
try Task.checkCancellation()
guard target == catalogTarget else { return }
scopedCatalog = result.objectValue ?? [:]
loadedCatalogTarget = target
} catch is CancellationError { return }
catch {
guard !Task.isCancelled, target == catalogTarget else { return }
scopedCatalog = [:]
loadedCatalogTarget = target
catalogError = "Could not load this project's commands. Close and reopen the menu to retry."
}
}

var body: some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: 0) {
if isLoading { ProgressView("Searching files…").padding(10) }
if catalogIsLoading { ProgressView("Loading commands…").font(.caption).padding(10) }
if needsScopedCatalog, loadedCatalogTarget == catalogTarget, let catalogError { Text(catalogError).font(.caption).padding(10) }
if let errorMessage { Text(errorMessage).font(.caption).padding(10) }
if suggestions.isEmpty && !isLoading && errorMessage == nil { Text("No matching suggestions").font(.caption).padding(10) }
if suggestions.isEmpty && !isLoading && !catalogIsLoading && catalogError == nil && errorMessage == nil { Text("No matching suggestions").font(.caption).padding(10) }
ForEach(suggestions) { suggestion in
Button { select(suggestion, trigger) } label: {
HStack(spacing: 9) {
Expand All @@ -30,12 +70,16 @@ struct NewAgentThreadSuggestions: View {
}
}.frame(maxHeight: 170).background(.quaternary.opacity(0.4), in: .rect(cornerRadius: 12))
.task(id: trigger) { await searchPaths() }
.task(id: catalogTarget) { await loadComposerCatalog() }
}

private var provider: [String: JSONValue] {
model.serverConfig["providers"]?.arrayValue?.compactMap(\.objectValue)
.first { $0["instanceId"]?.stringValue == model.selectedProviderID } ?? [:]
}

private var suggestions: [AgentThreadComposerSuggestion] {
if trigger.kind == .path { return loadedTrigger == trigger ? paths : [] }
let provider = model.serverConfig["providers"]?.arrayValue?.compactMap(\.objectValue)
.first { $0["instanceId"]?.stringValue == model.selectedProviderID } ?? [:]
var candidates: [AgentThreadComposerSuggestion] = []
switch trigger.kind {
case .model:
Expand All @@ -44,8 +88,8 @@ struct NewAgentThreadSuggestions: View {
action: .model(.init(instanceId: provider.id, model: value.id, options: nil)))
} }
case .skill:
candidates = (provider["skills"]?.arrayValue ?? []).compactMap { value in
guard let fields = value.objectValue, fields["enabled"]?.boolValue == true, let name = fields["name"]?.stringValue else { return nil }
candidates = (composerCatalog["skills"]?.arrayValue ?? []).compactMap { value in
guard let fields = value.objectValue, fields["enabled"]?.boolValue == true, fields["userInvocable"]?.boolValue != false, let name = fields["name"]?.stringValue else { return nil }
return .init(id: "skill:\(name)", title: fields["displayName"]?.stringValue ?? name,
detail: fields["shortDescription"]?.stringValue ?? fields["description"]?.stringValue ?? "$\(name)", symbol: "sparkles", action: .insert("$\(name) "))
}
Expand All @@ -55,7 +99,7 @@ struct NewAgentThreadSuggestions: View {
candidates += [.init(id: "builtin:plan", title: "/plan", detail: "Plan the work", symbol: "list.bullet.clipboard", action: .mode("plan")),
.init(id: "builtin:default", title: "/default", detail: "Work mode", symbol: "text.bubble", action: .mode("default"))]
}
candidates += (provider["slashCommands"]?.arrayValue ?? []).compactMap { value in
candidates += (trigger.range.location == 0 ? composerCatalog["slashCommands"]?.arrayValue ?? [] : []).compactMap { value in
guard let fields = value.objectValue, let name = fields["name"]?.stringValue else { return nil }
return .init(id: "command:\(name)", title: "/\(name)", detail: fields["description"]?.stringValue ?? "Provider command",
symbol: "terminal", action: .insert("/\(name) "))
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.projectsMutate]: AuthOrchestrationOperateScope,
[WS_METHODS.serverProbe]: AuthOrchestrationReadScope,
[WS_METHODS.serverGetConfig]: AuthOrchestrationReadScope,
[WS_METHODS.serverGetComposerCatalog]: AuthOrchestrationReadScope,
[WS_METHODS.serverRefreshProviders]: AuthOrchestrationOperateScope,
[WS_METHODS.serverStartProviderAuthentication]: AuthOrchestrationOperateScope,
[WS_METHODS.serverCompleteProviderAuthentication]: AuthOrchestrationOperateScope,
Expand Down
Loading