Skip to content

Commit 540d4fb

Browse files
authored
Add a share button when logged into cloud (RooCodeInc#4448)
* Add a share button when logged into cloud * Only show share button if enabled * PR feedback
1 parent 73e4418 commit 540d4fb

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

48 files changed

+513
-50
lines changed

packages/cloud/src/AuthService.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type { CloudUserInfo } from "@roo-code/types"
99

1010
import { getClerkBaseUrl, getRooCodeApiUrl } from "./Config"
1111
import { RefreshTimer } from "./RefreshTimer"
12+
import { getUserAgent } from "./utils"
1213

1314
export interface AuthServiceEvents {
1415
"inactive-session": [data: { previousState: AuthState }]
@@ -435,7 +436,7 @@ export class AuthService extends EventEmitter<AuthServiceEvents> {
435436
}
436437

437438
private userAgent(): string {
438-
return `Roo-Code ${this.context.extension?.packageJSON?.version}`
439+
return getUserAgent(this.context)
439440
}
440441

441442
private static _instance: AuthService | null = null

packages/cloud/src/CloudService.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { CloudServiceCallbacks } from "./types"
77
import { AuthService } from "./AuthService"
88
import { SettingsService } from "./SettingsService"
99
import { TelemetryClient } from "./TelemetryClient"
10+
import { ShareService } from "./ShareService"
1011

1112
export class CloudService {
1213
private static _instance: CloudService | null = null
@@ -17,6 +18,7 @@ export class CloudService {
1718
private authService: AuthService | null = null
1819
private settingsService: SettingsService | null = null
1920
private telemetryClient: TelemetryClient | null = null
21+
private shareService: ShareService | null = null
2022
private isInitialized = false
2123
private log: (...args: unknown[]) => void
2224

@@ -48,6 +50,8 @@ export class CloudService {
4850

4951
this.telemetryClient = new TelemetryClient(this.authService, this.settingsService)
5052

53+
this.shareService = new ShareService(this.authService, this.settingsService, this.log)
54+
5155
try {
5256
TelemetryService.instance.register(this.telemetryClient)
5357
} catch (error) {
@@ -112,6 +116,18 @@ export class CloudService {
112116
this.telemetryClient!.capture(event)
113117
}
114118

119+
// ShareService
120+
121+
public async shareTask(taskId: string): Promise<boolean> {
122+
this.ensureInitialized()
123+
return this.shareService!.shareTask(taskId)
124+
}
125+
126+
public async canShareTask(): Promise<boolean> {
127+
this.ensureInitialized()
128+
return this.shareService!.canShareTask()
129+
}
130+
115131
// Lifecycle
116132

117133
public dispose(): void {
@@ -128,7 +144,13 @@ export class CloudService {
128144
}
129145

130146
private ensureInitialized(): void {
131-
if (!this.isInitialized || !this.authService || !this.settingsService || !this.telemetryClient) {
147+
if (
148+
!this.isInitialized ||
149+
!this.authService ||
150+
!this.settingsService ||
151+
!this.telemetryClient ||
152+
!this.shareService
153+
) {
132154
throw new Error("CloudService not initialized.")
133155
}
134156
}

packages/cloud/src/ShareService.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import axios from "axios"
2+
import * as vscode from "vscode"
3+
4+
import { shareResponseSchema } from "@roo-code/types"
5+
import { getRooCodeApiUrl } from "./Config"
6+
import type { AuthService } from "./AuthService"
7+
import type { SettingsService } from "./SettingsService"
8+
import { getUserAgent } from "./utils"
9+
10+
export class ShareService {
11+
private authService: AuthService
12+
private settingsService: SettingsService
13+
private log: (...args: unknown[]) => void
14+
15+
constructor(authService: AuthService, settingsService: SettingsService, log?: (...args: unknown[]) => void) {
16+
this.authService = authService
17+
this.settingsService = settingsService
18+
this.log = log || console.log
19+
}
20+
21+
/**
22+
* Share a task: Create link and copy to clipboard
23+
* Returns true if successful, false if failed
24+
*/
25+
async shareTask(taskId: string): Promise<boolean> {
26+
try {
27+
const sessionToken = this.authService.getSessionToken()
28+
if (!sessionToken) {
29+
return false
30+
}
31+
32+
const response = await axios.post(
33+
`${getRooCodeApiUrl()}/api/extension/share`,
34+
{ taskId },
35+
{
36+
headers: {
37+
"Content-Type": "application/json",
38+
Authorization: `Bearer ${sessionToken}`,
39+
"User-Agent": getUserAgent(),
40+
},
41+
},
42+
)
43+
44+
const data = shareResponseSchema.parse(response.data)
45+
this.log("[share] Share link created successfully:", data)
46+
47+
if (data.success && data.shareUrl) {
48+
// Copy to clipboard
49+
await vscode.env.clipboard.writeText(data.shareUrl)
50+
return true
51+
} else {
52+
this.log("[share] Share failed:", data.error)
53+
return false
54+
}
55+
} catch (error) {
56+
this.log("[share] Error sharing task:", error)
57+
return false
58+
}
59+
}
60+
61+
/**
62+
* Check if sharing is available
63+
*/
64+
async canShareTask(): Promise<boolean> {
65+
try {
66+
if (!this.authService.isAuthenticated()) {
67+
return false
68+
}
69+
70+
return !!this.settingsService.getSettings()?.cloudSettings?.enableTaskSharing
71+
} catch (error) {
72+
this.log("[share] Error checking if task can be shared:", error)
73+
return false
74+
}
75+
}
76+
}
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
/* eslint-disable @typescript-eslint/no-explicit-any */
2+
import { describe, it, expect, beforeEach, vi, type MockedFunction } from "vitest"
3+
import axios from "axios"
4+
import * as vscode from "vscode"
5+
6+
import { ShareService } from "../ShareService"
7+
import type { AuthService } from "../AuthService"
8+
import type { SettingsService } from "../SettingsService"
9+
10+
// Mock axios
11+
vi.mock("axios")
12+
const mockedAxios = axios as any
13+
14+
// Mock vscode
15+
vi.mock("vscode", () => ({
16+
window: {
17+
showInformationMessage: vi.fn(),
18+
showErrorMessage: vi.fn(),
19+
},
20+
env: {
21+
clipboard: {
22+
writeText: vi.fn(),
23+
},
24+
openExternal: vi.fn(),
25+
},
26+
Uri: {
27+
parse: vi.fn(),
28+
},
29+
extensions: {
30+
getExtension: vi.fn(() => ({
31+
packageJSON: { version: "1.0.0" },
32+
})),
33+
},
34+
}))
35+
36+
// Mock config
37+
vi.mock("../Config", () => ({
38+
getRooCodeApiUrl: () => "https://app.roocode.com",
39+
}))
40+
41+
// Mock utils
42+
vi.mock("../utils", () => ({
43+
getUserAgent: () => "Roo-Code 1.0.0",
44+
}))
45+
46+
describe("ShareService", () => {
47+
let shareService: ShareService
48+
let mockAuthService: AuthService
49+
let mockSettingsService: SettingsService
50+
let mockLog: MockedFunction<(...args: unknown[]) => void>
51+
52+
beforeEach(() => {
53+
vi.clearAllMocks()
54+
55+
mockLog = vi.fn()
56+
mockAuthService = {
57+
hasActiveSession: vi.fn(),
58+
getSessionToken: vi.fn(),
59+
isAuthenticated: vi.fn(),
60+
} as any
61+
62+
mockSettingsService = {
63+
getSettings: vi.fn(),
64+
} as any
65+
66+
shareService = new ShareService(mockAuthService, mockSettingsService, mockLog)
67+
})
68+
69+
describe("shareTask", () => {
70+
it("should share task and copy to clipboard", async () => {
71+
const mockResponse = {
72+
data: {
73+
success: true,
74+
shareUrl: "https://app.roocode.com/share/abc123",
75+
},
76+
}
77+
78+
;(mockAuthService.hasActiveSession as any).mockReturnValue(true)
79+
;(mockAuthService.getSessionToken as any).mockReturnValue("session-token")
80+
mockedAxios.post.mockResolvedValue(mockResponse)
81+
82+
const result = await shareService.shareTask("task-123")
83+
84+
expect(result).toBe(true)
85+
expect(mockedAxios.post).toHaveBeenCalledWith(
86+
"https://app.roocode.com/api/extension/share",
87+
{ taskId: "task-123" },
88+
{
89+
headers: {
90+
"Content-Type": "application/json",
91+
Authorization: "Bearer session-token",
92+
"User-Agent": "Roo-Code 1.0.0",
93+
},
94+
},
95+
)
96+
expect(vscode.env.clipboard.writeText).toHaveBeenCalledWith("https://app.roocode.com/share/abc123")
97+
})
98+
99+
it("should handle API error response", async () => {
100+
const mockResponse = {
101+
data: {
102+
success: false,
103+
error: "Task not found",
104+
},
105+
}
106+
107+
;(mockAuthService.hasActiveSession as any).mockReturnValue(true)
108+
;(mockAuthService.getSessionToken as any).mockReturnValue("session-token")
109+
mockedAxios.post.mockResolvedValue(mockResponse)
110+
111+
const result = await shareService.shareTask("task-123")
112+
113+
expect(result).toBe(false)
114+
})
115+
116+
it("should handle authentication errors", async () => {
117+
;(mockAuthService.hasActiveSession as any).mockReturnValue(false)
118+
119+
const result = await shareService.shareTask("task-123")
120+
121+
expect(result).toBe(false)
122+
expect(mockedAxios.post).not.toHaveBeenCalled()
123+
})
124+
125+
it("should handle 403 error for disabled sharing", async () => {
126+
;(mockAuthService.hasActiveSession as any).mockReturnValue(true)
127+
;(mockAuthService.getSessionToken as any).mockReturnValue("session-token")
128+
129+
const error = {
130+
isAxiosError: true,
131+
response: {
132+
status: 403,
133+
data: {
134+
error: "Task sharing is not enabled for this organization",
135+
},
136+
},
137+
}
138+
139+
mockedAxios.isAxiosError.mockReturnValue(true)
140+
mockedAxios.post.mockRejectedValue(error)
141+
142+
const result = await shareService.shareTask("task-123")
143+
144+
expect(result).toBe(false)
145+
})
146+
147+
it("should handle unexpected errors", async () => {
148+
;(mockAuthService.hasActiveSession as any).mockReturnValue(true)
149+
;(mockAuthService.getSessionToken as any).mockReturnValue("session-token")
150+
151+
mockedAxios.post.mockRejectedValue(new Error("Network error"))
152+
153+
const result = await shareService.shareTask("task-123")
154+
155+
expect(result).toBe(false)
156+
})
157+
})
158+
159+
describe("canShareTask", () => {
160+
it("should return true when authenticated and sharing is enabled", async () => {
161+
;(mockAuthService.isAuthenticated as any).mockReturnValue(true)
162+
;(mockSettingsService.getSettings as any).mockReturnValue({
163+
cloudSettings: {
164+
enableTaskSharing: true,
165+
},
166+
})
167+
168+
const result = await shareService.canShareTask()
169+
170+
expect(result).toBe(true)
171+
})
172+
173+
it("should return false when authenticated but sharing is disabled", async () => {
174+
;(mockAuthService.isAuthenticated as any).mockReturnValue(true)
175+
;(mockSettingsService.getSettings as any).mockReturnValue({
176+
cloudSettings: {
177+
enableTaskSharing: false,
178+
},
179+
})
180+
181+
const result = await shareService.canShareTask()
182+
183+
expect(result).toBe(false)
184+
})
185+
186+
it("should return false when authenticated and sharing setting is undefined (default)", async () => {
187+
;(mockAuthService.isAuthenticated as any).mockReturnValue(true)
188+
;(mockSettingsService.getSettings as any).mockReturnValue({
189+
cloudSettings: {},
190+
})
191+
192+
const result = await shareService.canShareTask()
193+
194+
expect(result).toBe(false)
195+
})
196+
197+
it("should return false when authenticated and no settings available (default)", async () => {
198+
;(mockAuthService.isAuthenticated as any).mockReturnValue(true)
199+
;(mockSettingsService.getSettings as any).mockReturnValue(undefined)
200+
201+
const result = await shareService.canShareTask()
202+
203+
expect(result).toBe(false)
204+
})
205+
206+
it("should return false when not authenticated", async () => {
207+
;(mockAuthService.isAuthenticated as any).mockReturnValue(false)
208+
209+
const result = await shareService.canShareTask()
210+
211+
expect(result).toBe(false)
212+
})
213+
214+
it("should handle errors gracefully", async () => {
215+
;(mockAuthService.isAuthenticated as any).mockImplementation(() => {
216+
throw new Error("Auth error")
217+
})
218+
219+
const result = await shareService.canShareTask()
220+
221+
expect(result).toBe(false)
222+
})
223+
})
224+
})

packages/cloud/src/utils.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import * as vscode from "vscode"
2+
3+
/**
4+
* Get the User-Agent string for API requests
5+
* @param context Optional extension context for more accurate version detection
6+
* @returns User-Agent string in format "Roo-Code {version}"
7+
*/
8+
export function getUserAgent(context?: vscode.ExtensionContext): string {
9+
return `Roo-Code ${context?.extension?.packageJSON?.version || "unknown"}`
10+
}

0 commit comments

Comments
 (0)